diff --git a/css/apple2.css b/css/apple2.css index 4bc42a6..44cff2f 100644 --- a/css/apple2.css +++ b/css/apple2.css @@ -162,11 +162,8 @@ th { display: flex; } -.inset button { +.inset button, .modal-overlay button { min-width: 36px; -} - -.inset button { margin: 0 2px; } diff --git a/jest.config.js b/jest.config.js index a89c3bf..846f9f6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,14 +10,13 @@ module.exports = { 'testMatch': [ '**/?(*.)+(spec|test).+(ts|js|tsx)' ], - 'transform': { '^.+\\.js$': 'babel-jest', '^.+\\.ts$': 'ts-jest', '^.*\\.tsx$': 'ts-jest', }, 'setupFilesAfterEnv': [ - '/test/jest-setup.js' + '/test/jest-setup.ts' ], 'coveragePathIgnorePatterns': [ '/node_modules/', diff --git a/js/components/Apple2.tsx b/js/components/Apple2.tsx index b2c6a9d..1ff23de 100644 --- a/js/components/Apple2.tsx +++ b/js/components/Apple2.tsx @@ -12,6 +12,7 @@ import { Screen } from './Screen'; import { Drives } from './Drives'; import { Slinky } from './Slinky'; import { ThunderClock } from './ThunderClock'; +import { ErrorModal } from './ErrorModal'; /** * Interface for the Apple2 component. @@ -40,6 +41,7 @@ export const Apple2 = (props: Apple2Props) => { const [apple2, setApple2] = useState(); const [io, setIO] = useState(); const [cpu, setCPU] = useState(); + const [error, setError] = useState(); useEffect(() => { if (screen.current) { @@ -57,7 +59,13 @@ export const Apple2 = (props: Apple2Props) => { setCPU(cpu); apple2.reset(); apple2.run(); - }).catch(error => console.error(error)); + }).catch((e) => { + if (e instanceof Error) { + setError(e.message); + } else { + console.error(e); + } + }); } }, [props]); @@ -74,6 +82,7 @@ export const Apple2 = (props: Apple2Props) => { + ); }; diff --git a/js/components/DiskII.tsx b/js/components/DiskII.tsx index bf26f89..d22a275 100644 --- a/js/components/DiskII.tsx +++ b/js/components/DiskII.tsx @@ -4,6 +4,8 @@ import cs from 'classnames'; import Disk2 from '../cards/disk2'; import { FileModal } from './FileModal'; import { loadJSON, loadHttpFile, getHashParts } from './util/files'; +import { ErrorModal } from './ErrorModal'; +import { useHash } from './hooks/useHash'; /** * Storage structure for Disk II state returned via callbacks. @@ -38,25 +40,37 @@ export interface DiskIIProps extends DiskIIData { export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => { const label = side ? `${name} - ${side}` : name; const [modalOpen, setModalOpen] = useState(false); + const [error, setError] = useState(); + const [currentHash, setCurrentHash] = useState(); + + const hash = useHash(); + + const handleError = (e: unknown) => { + if (e instanceof Error) { + setError(e.message); + } else { + console.error(e); + } + }; useEffect(() => { - const hashParts = getHashParts(); - if (disk2 && hashParts && hashParts[number]) { - const hashPart = decodeURIComponent(hashParts[number]); - if (hashPart.match(/^https?:/)) { - loadHttpFile(disk2, number, hashPart) - .catch((error) => - console.error(error) - ); - } else { - const filename = `/json/disks/${hashPart}.json`; - loadJSON(disk2, number, filename) - .catch((error) => - console.error(error) - ); + const hashParts = getHashParts(hash); + const newHash = hashParts[number]; + if (disk2 && newHash) { + const hashPart = decodeURIComponent(newHash); + if (hashPart !== currentHash) { + if (hashPart.match(/^https?:/)) { + loadHttpFile(disk2, number, hashPart) + .catch((e) => handleError(e)); + } else { + const filename = `/json/disks/${hashPart}.json`; + loadJSON(disk2, number, filename) + .catch((e) => handleError(e)); + } + setCurrentHash(hashPart); } } - }, [disk2, number]); + }, [currentHash, disk2, hash, number]); const doClose = useCallback(() => { setModalOpen(false); @@ -69,6 +83,7 @@ export const DiskII = ({ disk2, number, on, name, side }: DiskIIProps) => { return (
+
void; +} + +export const ErrorModal = ({ error, setError } : ErrorProps) => { + const onClose = useCallback(() => setError(undefined), [setError]); + + return ( + <> + { error && ( + + +
+ {error} +
+
+ + + +
+ )} + + ); +}; diff --git a/js/components/FileChooser.tsx b/js/components/FileChooser.tsx index 0b3e2ad..648960b 100644 --- a/js/components/FileChooser.tsx +++ b/js/components/FileChooser.tsx @@ -27,7 +27,7 @@ export interface FileChooserProps { control?: typeof controlDefault; } -const hasPicker: boolean = !!window.showOpenFilePicker; +const hasPicker = !!window.showOpenFilePicker; const controlDefault = hasPicker ? 'picker' : 'input'; interface InputFileChooserProps { diff --git a/js/components/FileModal.tsx b/js/components/FileModal.tsx index 82fef1b..3c4ace4 100644 --- a/js/components/FileModal.tsx +++ b/js/components/FileModal.tsx @@ -1,13 +1,15 @@ -import { h, JSX } from 'preact'; +import { h, Fragment, JSX } from 'preact'; 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 { ErrorModal } from './ErrorModal'; import index from 'json/disks/index.json'; import { FileChooser, FilePickerAcceptType, FileSystemFileHandleLike } from './FileChooser'; import { noAwait } from './util/promises'; +import { useHash } from './hooks/useHash'; const DISK_TYPES: FilePickerAcceptType[] = [ { @@ -50,39 +52,39 @@ export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) => const [category, setCategory] = useState(); const [handles, setHandles] = useState(); const [filename, setFilename] = useState(); + const [error, setError] = useState(); + const hash = useHash(); const doCancel = useCallback(() => onClose(true), [onClose]); const doOpen = useCallback(async () => { - const hashParts = getHashParts(); + const hashParts = getHashParts(hash); + setBusy(true); - if (disk2 && handles && handles.length === 1) { - hashParts[number] = ''; - setBusy(true); - try { + try { + if (disk2 && handles?.length === 1) { + hashParts[number] = ''; await loadLocalFile(disk2, number, await handles[0].getFile()); - } catch (e) { - console.error(e); - } finally { - setBusy(false); - onClose(); } - } - - if (disk2 && filename) { - const name = filename.match(/\/([^/]+).json$/) || ['', '']; - hashParts[number] = name[1]; - setBusy(true); - loadJSON(disk2, number, filename) - .catch(console.error) - .finally(() => { - setBusy(false); - onClose(); - }); + if (disk2 && filename) { + const name = filename.match(/\/([^/]+).json$/) || ['', '']; + hashParts[number] = name[1]; + await loadJSON(disk2, number, filename); + } + } catch (e) { + if (e instanceof Error) { + setError(e.message); + } else { + console.error(e); + } + } finally { + setHashParts(hashParts); + setBusy(false); + onClose(); } setHashParts(hashParts); - }, [disk2, filename, number, onClose, handles]); + }, [disk2, filename, number, onClose, handles, hash]); const onChange = useCallback((handles: FileSystemFileHandleLike[]) => { setEmpty(handles.length === 0); @@ -105,29 +107,32 @@ export const FileModal = ({ disk2, number, onClose, isOpen }: FileModalProps) => const disks = category ? categories[category] : []; return ( - - -
- - -
- -
- - - - -
+ <> + + +
+ + +
+ +
+ + + + +
+ + ); }; diff --git a/js/components/Modal.tsx b/js/components/Modal.tsx index 6b34ce1..5e20345 100644 --- a/js/components/Modal.tsx +++ b/js/components/Modal.tsx @@ -1,4 +1,5 @@ import { h, ComponentChildren } from 'preact'; +import { createPortal } from 'preact/compat'; import { useCallback } from 'preact/hooks'; import { useHotKey } from './hooks/useHotKey'; @@ -69,7 +70,7 @@ const modalFooterStyle = { */ export const ModalOverlay = ({ children }: { children: ComponentChildren }) => { return ( -
+
{children}
); @@ -95,9 +96,9 @@ export const ModalContent = ({ children }: { children: ComponentChildren }) => { */ export const ModalFooter = ({ children }: { children: ComponentChildren }) => { return ( -
+
{children} -
+ ); }; @@ -135,6 +136,7 @@ type OnCloseCallback = (closeBox?: boolean) => void; export interface ModalHeaderProps { onClose?: OnCloseCallback; title: string; + icon?: string; } /** @@ -144,12 +146,16 @@ export interface ModalHeaderProps { * @param title Modal title * @returns ModalHeader component */ -export const ModalHeader = ({ onClose, title }: ModalHeaderProps) => { +export const ModalHeader = ({ onClose, title, icon }: ModalHeaderProps) => { return ( -
- {title} +
+ + {icon && } + {' '} + {title} + {onClose && } -
+ ); }; @@ -161,6 +167,7 @@ export interface ModalProps { isOpen: boolean; title: string; children: ComponentChildren; + icon?: string; } /** @@ -180,13 +187,13 @@ export const Modal = ({ ...props }: ModalProps) => { return ( - isOpen ? ( + isOpen ? createPortal(( -
+
{title && } {children}
- ) : null + ), document.body) : null ); }; diff --git a/js/components/hooks/useHash.ts b/js/components/hooks/useHash.ts new file mode 100644 index 0000000..acfb0fa --- /dev/null +++ b/js/components/hooks/useHash.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'preact/hooks'; + +export const useHash = () => { + const [hash, setHash] = useState(window.location.hash); + + const popstateListener = () => { + const hash = window.location.hash; + setHash(hash); + }; + + useEffect(() => { + window.addEventListener('popstate', popstateListener); + return () => { + window.removeEventListener('popstate', popstateListener); + }; + }, []); + + return hash; +}; diff --git a/js/components/util/files.ts b/js/components/util/files.ts index 405c6a7..6d5f52e 100644 --- a/js/components/util/files.ts +++ b/js/components/util/files.ts @@ -12,8 +12,8 @@ import DiskII from 'js/cards/disk2'; * * @returns an padded array for 1 based indexing */ -export const getHashParts = () => { - const parts = window.location.hash.match(/^#([^|]*)\|?(.*)$/) || ['', '', '']; +export const getHashParts = (hash: string) => { + const parts = hash.match(/^#([^|]*)\|?(.*)$/) || ['', '', '']; return ['', parts[1], parts[2]]; }; @@ -60,7 +60,7 @@ export const loadLocalFile = ( } } } else { - reject(`Extension ${ext} not recognized.`); + reject(`Extension "${ext}" not recognized.`); } }; fileReader.readAsArrayBuffer(file); @@ -139,7 +139,7 @@ export const loadHttpFile = async ( const ext = fileParts.pop()?.toLowerCase() || '[none]'; const name = decodeURIComponent(fileParts.join('.')); if (!includes(NIBBLE_FORMATS, ext)) { - throw new Error(`Extension ${ext} not recognized.`); + throw new Error(`Extension "${ext}" not recognized.`); } disk2.setBinary(number, name, ext, data); initGamepad(); diff --git a/package-lock.json b/package-lock.json index d94822e..03c6ea8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@babel/preset-env": "^7.9.0", "@babel/preset-typescript": "^7.16.7", "@testing-library/dom": "^7.30.3", + "@testing-library/jest-dom": "^5.16.4", "@testing-library/preact": "^3.0.1", "@testing-library/user-event": "^13.1.3", "@types/jest": "^27.0.2", @@ -2906,6 +2907,104 @@ "node": ">=8" } }, + "node_modules/@testing-library/jest-dom": { + "version": "5.16.4", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz", + "integrity": "sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=8", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/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/jest-dom/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/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/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/jest-dom/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/jest-dom/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/jest-dom/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/preact": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.1.1.tgz", @@ -3480,6 +3579,15 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.14.3", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz", + "integrity": "sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==", + "dev": true, + "dependencies": { + "@types/jest": "*" + } + }, "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", @@ -4260,6 +4368,18 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/babel-jest": { "version": "27.2.4", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.2.4.tgz", @@ -5099,6 +5219,32 @@ "node": ">= 8" } }, + "node_modules/css": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz", + "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==", + "dev": true, + "dependencies": { + "inherits": "^2.0.4", + "source-map": "^0.6.1", + "source-map-resolve": "^0.6.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, + "node_modules/css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", @@ -5160,6 +5306,15 @@ "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==", "dev": true }, + "node_modules/decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==", + "dev": true, + "engines": { + "node": ">=0.10" + } + }, "node_modules/decompress-response": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", @@ -11256,6 +11411,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -12190,6 +12354,19 @@ "node": ">= 0.10" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -12755,6 +12932,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-resolve": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", + "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==", + "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", + "dev": true, + "dependencies": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0" + } + }, "node_modules/source-map-support": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", @@ -12973,6 +13161,18 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -16595,6 +16795,80 @@ } } }, + "@testing-library/jest-dom": { + "version": "5.16.4", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.4.tgz", + "integrity": "sha512-Gy+IoFutbMQcky0k+bqqumXZ1cTGswLsFqmNLzNdSKkU9KGV2u9oXhukCbbJ9/LRPKiqwxEE8VpV/+YZlfkPUA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "dependencies": { + "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": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "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 + }, + "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/preact": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@testing-library/preact/-/preact-3.1.1.tgz", @@ -17092,6 +17366,15 @@ "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", "dev": true }, + "@types/testing-library__jest-dom": { + "version": "5.14.3", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz", + "integrity": "sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==", + "dev": true, + "requires": { + "@types/jest": "*" + } + }, "@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", @@ -17683,6 +17966,12 @@ "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", "dev": true }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true + }, "babel-jest": { "version": "27.2.4", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.2.4.tgz", @@ -18347,6 +18636,31 @@ "which": "^2.0.1" } }, + "css": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz", + "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==", + "dev": true, + "requires": { + "inherits": "^2.0.4", + "source-map": "^0.6.1", + "source-map-resolve": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", @@ -18396,6 +18710,12 @@ "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==", "dev": true }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==", + "dev": true + }, "decompress-response": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz", @@ -23010,6 +23330,12 @@ "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==", "dev": true }, + "min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true + }, "minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -23716,6 +24042,16 @@ "resolve": "^1.9.0" } }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -24162,6 +24498,16 @@ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", "dev": true }, + "source-map-resolve": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz", + "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==", + "dev": true, + "requires": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0" + } + }, "source-map-support": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", @@ -24344,6 +24690,15 @@ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true }, + "strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/package.json b/package.json index 619bc6d..819d5e3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@babel/preset-env": "^7.9.0", "@babel/preset-typescript": "^7.16.7", "@testing-library/dom": "^7.30.3", + "@testing-library/jest-dom": "^5.16.4", "@testing-library/preact": "^3.0.1", "@testing-library/user-event": "^13.1.3", "@types/jest": "^27.0.2", diff --git a/test/components/ErrorModal.spec.tsx b/test/components/ErrorModal.spec.tsx new file mode 100644 index 0000000..2e23de4 --- /dev/null +++ b/test/components/ErrorModal.spec.tsx @@ -0,0 +1,45 @@ +/** @jest-environment jsdom */ +import { h } from 'preact'; +import { fireEvent, render, screen } from '@testing-library/preact'; +import { + ErrorModal, +} from 'js/components/ErrorModal'; + +describe('ErrorModal', () => { + it('renders when there is an error', () => { + const setError = jest.fn(); + render( + + ); + expect(screen.queryByRole('banner')).toBeVisible(); + expect(screen.queryByRole('banner')).toHaveTextContent('Error'); + expect(screen.queryByText('My Error')).toBeVisible(); + }); + + it('does not render when there is not an error', () => { + const setError = jest.fn(); + render( + + ); + expect(screen.queryByRole('banner')).not.toBeInTheDocument(); + expect(screen.queryByText('My Error')).not.toBeInTheDocument(); + }); + + it('calls setError when close is clicked', () => { + const setError = jest.fn(); + render( + + ); + fireEvent.click(screen.getByTitle('Close')); + expect(setError).toHaveBeenCalledWith(undefined); + }); + + it('calls setError when OK is clicked', () => { + const setError = jest.fn(); + render( + + ); + fireEvent.click(screen.getByText('OK')); + expect(setError).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/test/components/Modal.spec.tsx b/test/components/Modal.spec.tsx new file mode 100644 index 0000000..a1ed9a5 --- /dev/null +++ b/test/components/Modal.spec.tsx @@ -0,0 +1,97 @@ +/** @jest-environment jsdom */ +import { h } from 'preact'; +import { fireEvent, render, screen } from '@testing-library/preact'; +import { + Modal, + ModalContent, + ModalFooter, +} from 'js/components/Modal'; + +describe('Modal', () => { + it('renders a title and content when open', () => { + render( + + + My Content + + + ); + expect(screen.queryByRole('banner')).toBeVisible(); + expect(screen.queryByRole('banner')).toHaveTextContent('My Title'); + expect(screen.queryByText('My Content')).toBeVisible(); + + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.queryByTitle('Close')).not.toBeInTheDocument(); + }); + + it('does not render a title and content when not open', () => { + render( + + + My Content + + + ); + expect(screen.queryByRole('banner')).not.toBeInTheDocument(); + expect(screen.queryByText('My Content')).not.toBeInTheDocument(); + }); + + it('renders a footer', () => { + render( + + + My Content + + + My Footer + + + ); + expect(screen.queryByRole('banner')).toHaveTextContent('My Title'); + expect(screen.queryByText('My Content')).toBeVisible(); + expect(screen.getByRole('contentinfo')).toHaveTextContent('My Footer'); + }); + + it('can have a close button', () => { + const onClose = jest.fn(); + render( + + + My Content + + + ); + const button = screen.getByTitle('Close'); + expect(button).toBeVisible(); + fireEvent.click(button); + expect(onClose).toHaveBeenCalledWith(true); + }); + + it('can have an icon', () => { + render( + + + My Content + + + ); + expect(screen.getByRole('img')).toBeVisible(); + }); +}); diff --git a/test/jest-setup.js b/test/jest-setup.ts similarity index 73% rename from test/jest-setup.js rename to test/jest-setup.ts index 579dbe1..5f03d11 100644 --- a/test/jest-setup.js +++ b/test/jest-setup.ts @@ -1,3 +1,4 @@ import { toMatchImageSnapshot } from 'jest-image-snapshot'; +import '@testing-library/jest-dom'; expect.extend({ toMatchImageSnapshot });