diff --git a/__TESTS__/css.spec.tsx b/__TESTS__/css.spec.tsx new file mode 100644 index 0000000..0bab19d --- /dev/null +++ b/__TESTS__/css.spec.tsx @@ -0,0 +1,75 @@ +/* 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 Css from '../pages/css'; + +describe('CSS', () => { + it('formats CSS correctly', async () => { + const user = userEvent.setup(); + render(); + + const css = screen.getByLabelText(/^CSS$/i); + const formattedCss = screen.getByLabelText(/FormattedCSS/i); + + await user.clear(css); + await user.type( + css, + 'body{background:#ffffff;}'.replace(/[{[]/g, '$&$&'), + ); + expect(formattedCss.innerHTML).toBe(`body { + background: #ffffff; +} +`); + }); + + it('minifies CSS correctly', async () => { + const user = userEvent.setup(); + render(); + + const css = screen.getByLabelText(/^CSS$/i); + const minifySwitch = screen.getByRole('checkbox', { + name: /Minify/i, + }); + + await user.clear(css); + await user.type( + css, + `body{ + background:#ffffff; + }`.replace(/[{[]/g, '$&$&'), + ); + + await user.click(minifySwitch); + const minifiedCss = screen.getByLabelText(/MinifiedCSS/i); + + expect(minifiedCss.innerHTML).toBe('body{background:#fff}'); + + await user.click(minifySwitch); + }); + + it('clears inputs with either clear button', async () => { + const user = userEvent.setup(); + render(); + + const css = screen.getByLabelText(/^CSS$/i); + const formattedCss = screen.getByLabelText(/FormattedCSS/i); + + const clearBtns = screen.getAllByRole('button', { name: /Clear/i }); + + await user.clear(css); + await user.type(css, 'body{}'.replace(/[{[]/g, '$&$&')); + await user.click(clearBtns[0]); + + expect(css).toHaveValue(''); + expect(formattedCss).toHaveValue(''); + + await user.type(css, 'body{}'.replace(/[{[]/g, '$&$&')); + await user.click(clearBtns[1]); + + expect(css).toHaveValue(''); + expect(formattedCss).toHaveValue(''); + }); +}); diff --git a/components/css/CssForm.tsx b/components/css/CssForm.tsx new file mode 100644 index 0000000..3d0b9cc --- /dev/null +++ b/components/css/CssForm.tsx @@ -0,0 +1,217 @@ +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 Grid from '@mui/material/Grid'; +import Switch from '@mui/material/Switch'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import { minify } from 'csso'; +import prettier from 'prettier'; +import parserCss from 'prettier/parser-postcss'; +import React from 'react'; + +import useSupportsClipboardRead from '../../hooks/useSupportsClipboardRead'; +import type { ToastProps } from '../Toast'; + +export interface CssFormProps { + css: string | void; + formattedCss: string | void; + error: string; + minifyCss: boolean; + setCss: (_css: string | void) => void; + setFormattedCss: (_formattedCss: string | void) => void; + setError: (_error: string) => void; + setMinifyCss: (_minifyCss: boolean) => void; + setToastMessage: (_toastMessage: string) => void; + setToastOpen: (_toastOpen: boolean) => void; + setToastSeverity: (_toastSeverity: ToastProps['severity']) => void; +} + +export default function CssForm({ + css, + formattedCss, + error, + minifyCss, + setCss, + setFormattedCss, + setError, + setMinifyCss, + setToastMessage, + setToastOpen, + setToastSeverity, +}: CssFormProps) { + const supportsClipboardRead = useSupportsClipboardRead(); + function calculateFormattedCss( + value: string | void, + minification: boolean | false, + ) { + setCss(value); + if (!value) { + setFormattedCss(''); + } + try { + const formatted = prettier.format(value ?? '', { + parser: 'css', + plugins: [parserCss], + }); + + if (minification) { + const minified = minify(formatted).css; + setFormattedCss(minified); + } else { + setFormattedCss(formatted); + } + setError(''); + } catch (e: unknown) { + setError((e as { message: string })?.message ?? ''); + setFormattedCss(''); + } + } + + function handleChange(event: React.ChangeEvent) { + const { value, name } = event.target; + if (name === 'css') { + calculateFormattedCss(value, minifyCss); + } + if (name === 'minifySwitch') { + calculateFormattedCss(css, !minifyCss); + setMinifyCss(!minifyCss); + } + } + + return ( + + + 0} + label='CSS' + name='css' + onChange={handleChange} + value={css} + /> + + + {!!supportsClipboardRead && ( + + )} + + {error && ( +
+            
+              {error}
+            
+          
+ )} +
+ + Format + + + + Minify + + + + + + + + +
+ ); +} diff --git a/data/nav.ts b/data/nav.ts index 9bb7c17..a6f0aa6 100644 --- a/data/nav.ts +++ b/data/nav.ts @@ -1,5 +1,6 @@ import AspectRatio from '@mui/icons-material/AspectRatio'; import Code from '@mui/icons-material/Code'; +import Css from '@mui/icons-material/Css'; import DataObject from '@mui/icons-material/DataObject'; import Fingerprint from '@mui/icons-material/Fingerprint'; import HomeIcon from '@mui/icons-material/Home'; @@ -19,6 +20,11 @@ export default [ href: '/aspect-ratio', Icon: AspectRatio, }, + { + title: 'CSS', + href: '/css', + Icon: Css, + }, { title: 'UUID', href: '/uuid', diff --git a/package-lock.json b/package-lock.json index f9f0711..4a00465 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@mui/icons-material": "^5.10.3", "@mui/material": "^5.10.3", "clsx": "^1.2.1", + "csso": "^5.0.5", "js-base64": "^3.7.2", "next": "12.2.5", "next-images": "^1.8.4", @@ -4822,12 +4823,37 @@ "node": ">=8" } }, + "node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.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/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -8470,6 +8496,11 @@ "tmpl": "1.0.5" } }, + "node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -14524,12 +14555,29 @@ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==" }, + "css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "requires": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + } + }, "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 }, + "csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "requires": { + "css-tree": "~2.2.0" + } + }, "cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -17194,6 +17242,11 @@ "tmpl": "1.0.5" } }, + "mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==" + }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index 6a2921c..d238e4f 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@mui/icons-material": "^5.10.3", "@mui/material": "^5.10.3", "clsx": "^1.2.1", + "csso": "^5.0.5", "js-base64": "^3.7.2", "next": "12.2.5", "next-images": "^1.8.4", diff --git a/pages/css.tsx b/pages/css.tsx new file mode 100644 index 0000000..60a6532 --- /dev/null +++ b/pages/css.tsx @@ -0,0 +1,61 @@ +import Typography from '@mui/material/Typography'; +import React, { useState } from 'react'; + +import CssForm from '../components/css/CssForm'; +import Heading from '../components/Heading'; +import Layout from '../components/Layout'; +import Toast, { ToastProps } from '../components/Toast'; +import useLocalState from '../hooks/useLocalState'; + +export default function CssPage() { + const [css, setCss] = useLocalState({ + key: 'css', + defaultValue: '', + }); + const [formattedCss, setFormattedCss] = useLocalState({ + key: 'cssFormatted', + defaultValue: '', + }); + const [minifyCss, setMinifyCss] = useLocalState({ + key: 'cssMinify', + defaultValue: false, + }); + + const [error, setError] = React.useState(''); + const [toastOpen, setToastOpen] = useState(false); + const [toastMessage, setToastMessage] = useState(''); + const [toastSeverity, setToastSeverity] = + useState('success'); + + return ( + + Minify CSS + + Paste or type in some CSS to format or minify it. + + + Note: Formatting works with CSS, SCSS, and LESS. Minification + only works for raw CSS. + + + setToastOpen(false)} + /> + + ); +}