diff --git a/__TESTS__/html.spec.tsx b/__TESTS__/html.spec.tsx new file mode 100644 index 0000000..95188c6 --- /dev/null +++ b/__TESTS__/html.spec.tsx @@ -0,0 +1,45 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +/* eslint-enable import/no-extraneous-dependencies */ +import React from 'react'; + +import Html from '../pages/html'; + +describe('HTML', () => { + it('formats HTML and adds newlines', async () => { + const user = userEvent.setup(); + render(); + + const html = screen.getByLabelText(/^HTML$/i); + const formattedHtml = screen.getByLabelText(/FormattedHTML/i); + + await user.clear(html); + await user.type(html, ''); + expect(formattedHtml.innerHTML).toContain('</html>'); + expect(formattedHtml.innerHTML).toContain('\n'); + }); + + it('clears inputs with either clear button', async () => { + const user = userEvent.setup(); + render(); + + const html = screen.getByLabelText(/^HTML$/i); + const formattedHtml = screen.getByLabelText(/FormattedHTML/i); + + const clearBtns = screen.getAllByRole('button', { name: /Clear/i }); + + await user.clear(html); + await user.type(html, ''); + await user.click(clearBtns[0]); + + expect(html).toHaveValue(''); + expect(formattedHtml).toHaveValue(''); + + await user.type(html, ''); + await user.click(clearBtns[1]); + + expect(html).toHaveValue(''); + expect(formattedHtml).toHaveValue(''); + }); +}); diff --git a/components/html/HtmlFormat.tsx b/components/html/HtmlFormat.tsx new file mode 100644 index 0000000..0621f10 --- /dev/null +++ b/components/html/HtmlFormat.tsx @@ -0,0 +1,182 @@ +import ClearIcon from '@mui/icons-material/Clear'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import prettier from 'prettier'; +import parserHtml from 'prettier/parser-html'; +import React from 'react'; + +import useSupportsClipboardRead from '../../hooks/useSupportsClipboardRead'; +import type { ToastProps } from '../Toast'; + +export interface HtmlFormatProps { + html: string | void; + formattedHtml: string | void; + error: string; + setHtml: (_html: string | void) => void; + setFormattedHtml: (_formattedHtml: string | void) => void; + setError: (_error: string) => void; + setToastMessage: (_toastMessage: string) => void; + setToastOpen: (_toastOpen: boolean) => void; + setToastSeverity: (_toastSeverity: ToastProps['severity']) => void; +} + +export default function HtmlFormat({ + html, + formattedHtml, + error, + setHtml, + setFormattedHtml, + setError, + setToastMessage, + setToastOpen, + setToastSeverity, +}: HtmlFormatProps) { + const supportsClipboardRead = useSupportsClipboardRead(); + function calculateFormattedHtml(value: string | void) { + setHtml(value); + if (!value) { + setFormattedHtml(''); + } + try { + const formatted = prettier.format(value ?? '', { + parser: 'html', + plugins: [parserHtml], + }); + + setError(''); + setFormattedHtml(formatted); + } catch (e: unknown) { + setError((e as { message: string })?.message ?? ''); + setFormattedHtml(''); + } + } + + function handleChange(event: React.ChangeEvent) { + const { value, name } = event.target; + if (name === 'html') { + calculateFormattedHtml(value); + } + } + + return ( + + + 0} + label='HTML' + name='html' + onChange={handleChange} + value={html} + /> + + + {!!supportsClipboardRead && ( + + )} + + {error && ( +
+            
+              {error}
+            
+          
+ )} +
+ + + + + + + +
+ ); +} diff --git a/data/nav.ts b/data/nav.ts index bbfad25..9bb7c17 100644 --- a/data/nav.ts +++ b/data/nav.ts @@ -1,4 +1,5 @@ import AspectRatio from '@mui/icons-material/AspectRatio'; +import Code from '@mui/icons-material/Code'; import DataObject from '@mui/icons-material/DataObject'; import Fingerprint from '@mui/icons-material/Fingerprint'; import HomeIcon from '@mui/icons-material/Home'; @@ -38,4 +39,9 @@ export default [ href: '/urlEncodeDecode', Icon: LinkIcon, }, + { + title: 'HTML', + href: '/html', + Icon: Code, + }, ]; diff --git a/package-lock.json b/package-lock.json index 07eaa1a..f9f0711 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "next": "12.2.5", "next-images": "^1.8.4", "next-pwa": "^5.6.0", + "prettier": "^2.7.1", "react": "18.2.0", "react-dom": "18.2.0", "uuid": "^9.0.0" @@ -9150,8 +9151,6 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", - "dev": true, - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -17646,9 +17645,7 @@ "prettier": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", - "dev": true, - "peer": true + "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==" }, "prettier-linter-helpers": { "version": "1.0.0", diff --git a/package.json b/package.json index b42ba48..6a2921c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "next": "12.2.5", "next-images": "^1.8.4", "next-pwa": "^5.6.0", + "prettier": "^2.7.1", "react": "18.2.0", "react-dom": "18.2.0", "uuid": "^9.0.0" diff --git a/pages/html.tsx b/pages/html.tsx new file mode 100644 index 0000000..ee265e6 --- /dev/null +++ b/pages/html.tsx @@ -0,0 +1,54 @@ +import Typography from '@mui/material/Typography'; +import React, { useState } from 'react'; + +import Heading from '../components/Heading'; +import HtmlFormat from '../components/html/HtmlFormat'; +import Layout from '../components/Layout'; +import Toast, { ToastProps } from '../components/Toast'; +import useLocalState from '../hooks/useLocalState'; + +export default function HtmlPage() { + const [html, setHtml] = useLocalState({ + key: 'html', + defaultValue: '', + }); + + const [formattedHtml, setFormattedHtml] = useLocalState< + string | void + >({ + key: 'htmlFormatted', + defaultValue: '', + }); + + const [error, setError] = React.useState(''); + const [toastOpen, setToastOpen] = useState(false); + const [toastMessage, setToastMessage] = useState(''); + const [toastSeverity, setToastSeverity] = + useState('success'); + + return ( + + HTML + + Paste or type in some unformatted HTML to format it + + + setToastOpen(false)} + /> + + ); +}