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}
+ />
+
+ }
+ disabled={!css}
+ onClick={() => {
+ setCss('');
+ setFormattedCss('');
+ }}
+ >
+ Clear
+
+ {!!supportsClipboardRead && (
+ }
+ onClick={async () => {
+ const text = await navigator.clipboard.readText();
+ if (text) {
+ setCss(text);
+ calculateFormattedCss(text, minifyCss);
+ }
+ }}
+ >
+ Paste
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+
+ )}
+
+
+ Format
+
+
+
+ Minify
+
+
+
+
+ }
+ disabled={!formattedCss}
+ onClick={() => {
+ setFormattedCss('');
+ setCss('');
+ }}
+ >
+ Clear
+
+ }
+ disabled={!formattedCss}
+ name='Copy'
+ onClick={() => {
+ navigator.clipboard.writeText(formattedCss || '').then(
+ () => {
+ setToastMessage('Copied to clipboard');
+ setToastSeverity('success');
+ setToastOpen(true);
+ },
+ () => {
+ setToastMessage('Failed to copy to clipboard');
+ setToastSeverity('error');
+ setToastOpen(true);
+ },
+ );
+ }}
+ >
+ Copy
+
+
+
+
+ );
+}
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)}
+ />
+
+ );
+}