├── .editorconfig ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── led.woff ├── led.woff2 ├── zig.woff └── zig.woff2 ├── readme.md ├── rollup.config.js └── src ├── lib ├── hooks.js └── utils.js ├── main.js ├── styles ├── global.css ├── index.css └── resets.css └── ui ├── app.css ├── app.js ├── board-selector.css ├── board-selector.js ├── board.css ├── board.js ├── button.css ├── button.js ├── cell.js ├── count-display.css ├── count-display.js ├── counter.js ├── icons.js ├── presets.js ├── radio.css ├── radio.js ├── ui.css ├── windows-ui.css └── windows-ui.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = tab 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [**.min.js] 13 | indent_style = ignore 14 | insert_final_newline = ignore 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | Minesweeper 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "refactoring-react-components-to-typescript", 3 | "private": true, 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/chaance/refactoring-react-components-to-typescript" 9 | }, 10 | "scripts": { 11 | "dev:js": "rollup -c rollup.config.js --watch", 12 | "dev:css": "postcss ./src/styles/index.css --output ./dist/styles.css --watch", 13 | "dev": "concurrently \"npm run dev:js\" \"npm run dev:css\" --kill-others", 14 | "start": "npm run dev", 15 | "build": "echo TODO 😅" 16 | }, 17 | "dependencies": { 18 | "@babel/core": "^7.16.0", 19 | "clsx": "^1.1.1", 20 | "react": "^17.0.2", 21 | "react-dom": "^17.0.2" 22 | }, 23 | "devDependencies": { 24 | "@babel/eslint-parser": "^7.16.3", 25 | "@babel/preset-env": "^7.16.4", 26 | "@babel/preset-react": "^7.16.0", 27 | "@chancedigital/eslint-config": "^9.0.0", 28 | "@rollup/plugin-babel": "^5.3.0", 29 | "@rollup/plugin-commonjs": "^21.0.1", 30 | "@rollup/plugin-node-resolve": "^13.0.6", 31 | "@rollup/plugin-replace": "^3.0.0", 32 | "@typescript-eslint/eslint-plugin": "^5.5.0", 33 | "@typescript-eslint/parser": "^5.5.0", 34 | "autoprefixer": "^10.4.0", 35 | "concurrently": "^6.4.0", 36 | "eslint": "^7.32.0", 37 | "eslint-plugin-import": "^2.25.3", 38 | "eslint-plugin-jest": "^25.3.0", 39 | "eslint-plugin-jsx-a11y": "^6.5.1", 40 | "eslint-plugin-react": "^7.27.1", 41 | "eslint-plugin-react-hooks": "^4.3.0", 42 | "eslint-plugin-testing-library": "^4.12.4", 43 | "postcss": "^8.4.4", 44 | "postcss-cli": "^9.0.2", 45 | "postcss-custom-media": "^8.0.0", 46 | "postcss-import": "^14.0.2", 47 | "postcss-nesting": "^10.0.2", 48 | "prettier": "^2.5.0", 49 | "rollup": "^2.60.2", 50 | "rollup-plugin-livereload": "^2.0.5", 51 | "rollup-plugin-serve": "^1.1.0" 52 | }, 53 | "eslintConfig": { 54 | "extends": [ 55 | "@chancedigital/eslint-config/jest", 56 | "@chancedigital/eslint-config/react", 57 | "@chancedigital/eslint-config/typescript" 58 | ], 59 | "rules": { 60 | "no-fallthrough": 0, 61 | "default-case": 0 62 | } 63 | }, 64 | "prettier": { 65 | "tabWidth": 2, 66 | "useTabs": true 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require("autoprefixer"), 4 | require("postcss-import"), 5 | require("postcss-nesting"), 6 | require("postcss-custom-media"), 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /public/led.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaance/refactoring-react-components-to-typescript/88ecfb2127f76a1a45cf75703f657ea34dc7afc7/public/led.woff -------------------------------------------------------------------------------- /public/led.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaance/refactoring-react-components-to-typescript/88ecfb2127f76a1a45cf75703f657ea34dc7afc7/public/led.woff2 -------------------------------------------------------------------------------- /public/zig.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaance/refactoring-react-components-to-typescript/88ecfb2127f76a1a45cf75703f657ea34dc7afc7/public/zig.woff -------------------------------------------------------------------------------- /public/zig.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaance/refactoring-react-components-to-typescript/88ecfb2127f76a1a45cf75703f657ea34dc7afc7/public/zig.woff2 -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Refactoring React Components to TypeScript 2 | 3 | This is the code for my upcoming egghead course. Stay tuned for the link! 4 | 5 | ## The App: Minesweeper 6 | 7 | A Windows 98 classic, on the web 💥 8 | 9 | ![Screenshot of the minesweeper game styled *extremely* close to the Windows 98 version](https://pbs.twimg.com/media/E_v_U8IVUAI259d?format=jpg&name=large) 10 | 11 | ### Get Started 12 | 13 | ```bash 14 | npm install 15 | npm run dev 16 | ``` 17 | 18 | To see the app in its state at the end of my course, check out the `final` branch. 19 | 20 | ```bash 21 | git checkout final 22 | ``` 23 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import serve from "rollup-plugin-serve"; 2 | import livereload from "rollup-plugin-livereload"; 3 | import babel from "@rollup/plugin-babel"; 4 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 5 | import commonjs from "@rollup/plugin-commonjs"; 6 | import replace from "@rollup/plugin-replace"; 7 | 8 | const config = { 9 | input: "src/main.js", 10 | output: { 11 | file: "dist/bundle.js", 12 | format: "iife", 13 | sourcemap: true, 14 | }, 15 | plugins: [ 16 | nodeResolve({ 17 | extensions: [".js"], 18 | }), 19 | replace({ 20 | preventAssignment: true, 21 | "process.env.NODE_ENV": JSON.stringify("development"), 22 | }), 23 | babel({ 24 | extensions: [".js"], 25 | babelHelpers: "bundled", 26 | presets: [ 27 | [ 28 | "@babel/preset-env", 29 | { 30 | loose: true, 31 | }, 32 | ], 33 | "@babel/preset-react", 34 | ], 35 | env: { 36 | development: { 37 | compact: false, 38 | }, 39 | }, 40 | }), 41 | commonjs(), 42 | serve({ 43 | open: true, 44 | verbose: true, 45 | contentBase: ["", "public"], 46 | host: "localhost", 47 | port: 3000, 48 | }), 49 | livereload({ watch: "dist" }), 50 | ], 51 | }; 52 | 53 | export default config; 54 | -------------------------------------------------------------------------------- /src/lib/hooks.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function useConsoleLog(...args) { 4 | React.useEffect(() => { 5 | console.log(...args); 6 | // eslint-disable-next-line react-hooks/exhaustive-deps 7 | }, args); 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/utils.js: -------------------------------------------------------------------------------- 1 | export function scope(name) { 2 | return name ? `ms--${name.replace(/^[-\s]+/g, "")}` : ""; 3 | } 4 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import App from "./ui/app"; 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById("root") 10 | ); 11 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | @custom-media --bp-xs (min-width: 480px); 2 | @custom-media --bp-sm (min-width: 640px); 3 | @custom-media --bp-md (min-width: 768px); 4 | @custom-media --bp-lg (min-width: 1024px); 5 | @custom-media --bp-xl (min-width: 1280px); 6 | @custom-media --bp-2xl (min-width: 1536px); 7 | @custom-media --bp-xs-max (max-width: 479px); 8 | @custom-media --bp-xs-down (max-width: 639px); 9 | @custom-media --bp-sm-down (max-width: 767px); 10 | @custom-media --bp-md-down (max-width: 1023px); 11 | @custom-media --bp-lg-down (max-width: 1279px); 12 | @custom-media --bp-xl-down (max-width: 1535px); 13 | 14 | @font-face { 15 | font-family: "zig"; 16 | src: url("/zig.woff2") format("woff2"), url("/zig.woff") format("woff"); 17 | font-weight: normal; 18 | font-style: normal; 19 | } 20 | 21 | @font-face { 22 | font-family: "led"; 23 | src: url("/led.woff2") format("woff2"), url("/led.woff") format("woff"); 24 | font-weight: normal; 25 | font-style: normal; 26 | } 27 | 28 | /* Definitions */ 29 | 30 | :root { 31 | --spacing-00: 0px; /* 0px */ 32 | --spacing-00-5: 0.125rem; /* 2px */ 33 | --spacing-01: 0.25rem; /* 4px */ 34 | --spacing-01-5: 0.375rem; /* 6px */ 35 | --spacing-02: 0.5rem; /* 8px */ 36 | --spacing-02-5: 0.625rem; /* 10px */ 37 | --spacing-03: 0.75rem; /* 12px */ 38 | --spacing-03-5: 0.875rem; /* 14px */ 39 | --spacing-04: 1rem; /* 16px */ 40 | --spacing-05: 1.25rem; /* 20px */ 41 | --spacing-06: 1.5rem; /* 24px */ 42 | --spacing-07: 1.75rem; /* 28px */ 43 | --spacing-08: 2rem; /* 32px */ 44 | --spacing-09: 2.25rem; /* 36px */ 45 | --spacing-10: 2.5rem; /* 40px */ 46 | --spacing-11: 2.75rem; /* 44px */ 47 | --spacing-12: 3rem; /* 48px */ 48 | --spacing-14: 3.5rem; /* 56px */ 49 | --spacing-16: 4rem; /* 64px */ 50 | --spacing-20: 5rem; /* 80px */ 51 | --spacing-24: 6rem; /* 96px */ 52 | --spacing-28: 7rem; /* 112px */ 53 | --spacing-32: 8rem; /* 128px */ 54 | --spacing-36: 9rem; /* 144px */ 55 | --spacing-40: 10rem; /* 160px */ 56 | --spacing-44: 11rem; /* 176px */ 57 | --spacing-48: 12rem; /* 192px */ 58 | --spacing-52: 13rem; /* 208px */ 59 | --spacing-56: 14rem; /* 224px */ 60 | --spacing-60: 15rem; /* 240px */ 61 | --spacing-64: 16rem; /* 256px */ 62 | --spacing-72: 18rem; /* 288px */ 63 | --spacing-80: 20rem; /* 320px */ 64 | --spacing-96: 24rem; /* 384px */ 65 | 66 | --border-0: 0px; 67 | --border-1: 1px; 68 | --border-2: 2px; 69 | --border-3: 3px; 70 | --border-4: 4px; 71 | --border-8: 8px; 72 | 73 | /* colors */ 74 | /* ------------------------------- */ 75 | /* -- white -- */ 76 | --color-white: hsl(0, 0%, 100%); 77 | --color-white-a01: hsla(0, 0%, 100%, 0); 78 | --color-white-a02: hsla(0, 0%, 100%, 0.013); 79 | --color-white-a03: hsla(0, 0%, 100%, 0.034); 80 | --color-white-a04: hsla(0, 0%, 100%, 0.056); 81 | --color-white-a05: hsla(0, 0%, 100%, 0.086); 82 | --color-white-a06: hsla(0, 0%, 100%, 0.124); 83 | --color-white-a07: hsla(0, 0%, 100%, 0.176); 84 | --color-white-a08: hsla(0, 0%, 100%, 0.249); 85 | --color-white-a09: hsla(0, 0%, 100%, 0.386); 86 | --color-white-a10: hsla(0, 0%, 100%, 0.446); 87 | --color-white-a11: hsla(0, 0%, 100%, 0.592); 88 | --color-white-a12: hsla(0, 0%, 100%, 0.923); 89 | 90 | /* -- black -- */ 91 | --color-black: hsl(210, 7%, 11%); 92 | --color-black-a01: hsla(210, 7%, 11%, 0.012); 93 | --color-black-a02: hsla(210, 7%, 11%, 0.027); 94 | --color-black-a03: hsla(210, 7%, 11%, 0.047); 95 | --color-black-a04: hsla(210, 7%, 11%, 0.071); 96 | --color-black-a05: hsla(210, 7%, 11%, 0.09); 97 | --color-black-a06: hsla(210, 7%, 11%, 0.114); 98 | --color-black-a07: hsla(210, 7%, 11%, 0.141); 99 | --color-black-a08: hsla(210, 7%, 11%, 0.22); 100 | --color-black-a09: hsla(210, 7%, 11%, 0.439); 101 | --color-black-a10: hsla(210, 7%, 11%, 0.478); 102 | --color-black-a11: hsla(210, 7%, 11%, 0.565); 103 | --color-black-a12: hsla(210, 7%, 11%, 0.91); 104 | 105 | /* -- gray -- */ 106 | --color-gray-00: var(--color-white); 107 | --color-gray-01: hsl(240, 33%, 98%); 108 | --color-gray-02: hsl(240, 17%, 97%); 109 | --color-gray-03: hsl(210, 8%, 95%); 110 | --color-gray-04: hsl(210, 6%, 93%); 111 | --color-gray-05: hsl(210, 9%, 91%); 112 | --color-gray-06: hsl(210, 7%, 89%); 113 | --color-gray-07: hsl(214, 7%, 79%); 114 | --color-gray-08: hsl(210, 7%, 69%); 115 | --color-gray-09: hsl(212, 7%, 59%); 116 | --color-gray-10: hsl(210, 7%, 49%); 117 | --color-gray-11: hsl(210, 7%, 38%); 118 | --color-gray-12: hsl(207, 7%, 25%); 119 | --color-gray-13: hsl(209, 10%, 18%); 120 | 121 | /* -- (*_*) -- */ 122 | --color-grey-00: var(--color-gray-00); 123 | --color-grey-01: var(--color-gray-01); 124 | --color-grey-02: var(--color-gray-02); 125 | --color-grey-03: var(--color-gray-03); 126 | --color-grey-04: var(--color-gray-04); 127 | --color-grey-05: var(--color-gray-05); 128 | --color-grey-06: var(--color-gray-06); 129 | --color-grey-07: var(--color-gray-07); 130 | --color-grey-08: var(--color-gray-08); 131 | --color-grey-09: var(--color-gray-09); 132 | --color-grey-10: var(--color-gray-10); 133 | --color-grey-11: var(--color-gray-11); 134 | --color-grey-12: var(--color-gray-12); 135 | 136 | --color-tile-01: #0a00ff; 137 | --color-tile-02: #027a01; 138 | --color-tile-03: #ff0100; 139 | --color-tile-04: #02007b; 140 | --color-tile-05: #7a0100; 141 | --color-tile-06: #037b7b; 142 | --color-tile-07: var(--color-black); 143 | --color-tile-08: var(--color-gray-09); 144 | --color-tile-exploded: #ff0100; 145 | --color-tile-exploded-border: #b81414; 146 | --color-clock-text: #ff0100; 147 | --font-tile-numbers: zig, sans-serif; 148 | --font-clock: led, monospace; 149 | } 150 | 151 | :focus { 152 | outline: 1px dotted var(--color-black); 153 | } 154 | 155 | html { 156 | min-height: 100vh; 157 | min-height: calc( 158 | 100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) 159 | ); 160 | background: #56aaaa; 161 | } 162 | 163 | body { 164 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 165 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 166 | sans-serif; 167 | -webkit-font-smoothing: antialiased; 168 | -moz-osx-font-smoothing: grayscale; 169 | min-height: inherit; 170 | } 171 | 172 | #root { 173 | min-height: inherit; 174 | } 175 | 176 | code { 177 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 178 | monospace; 179 | } 180 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | @import "./resets.css"; 3 | @import "./global.css"; 4 | @import "../ui/ui.css"; 5 | -------------------------------------------------------------------------------- /src/styles/resets.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | *::before, 7 | *::after { 8 | box-sizing: inherit; 9 | } 10 | 11 | html, 12 | body { 13 | margin: 0; 14 | } 15 | 16 | html, 17 | body, 18 | div, 19 | span, 20 | applet, 21 | object, 22 | iframe, 23 | h1, 24 | h2, 25 | h3, 26 | h4, 27 | h5, 28 | h6, 29 | p, 30 | blockquote, 31 | pre, 32 | a, 33 | abbr, 34 | acronym, 35 | address, 36 | big, 37 | cite, 38 | code, 39 | del, 40 | dfn, 41 | em, 42 | img, 43 | ins, 44 | kbd, 45 | q, 46 | s, 47 | samp, 48 | small, 49 | strike, 50 | strong, 51 | sub, 52 | sup, 53 | tt, 54 | var, 55 | b, 56 | u, 57 | i, 58 | center, 59 | dl, 60 | dt, 61 | dd, 62 | ol, 63 | ul, 64 | li, 65 | fieldset, 66 | form, 67 | label, 68 | legend, 69 | table, 70 | caption, 71 | tbody, 72 | tfoot, 73 | thead, 74 | tr, 75 | th, 76 | td, 77 | article, 78 | aside, 79 | canvas, 80 | details, 81 | dialog, 82 | embed, 83 | figure, 84 | figcaption, 85 | footer, 86 | header, 87 | hgroup, 88 | menu, 89 | nav, 90 | output, 91 | ruby, 92 | section, 93 | summary, 94 | time, 95 | mark, 96 | audio, 97 | video { 98 | margin: 0; 99 | padding: 0; 100 | border: 0; 101 | font-size: 100%; 102 | font: inherit; 103 | vertical-align: baseline; 104 | } 105 | 106 | body { 107 | line-height: 1; 108 | } 109 | 110 | ol, 111 | ul { 112 | list-style: none; 113 | } 114 | 115 | blockquote, 116 | q { 117 | quotes: none; 118 | } 119 | 120 | blockquote::before, 121 | blockquote::after, 122 | q::before, 123 | q::after { 124 | content: ""; 125 | content: none; 126 | } 127 | 128 | table { 129 | border-collapse: collapse; 130 | border-spacing: 0; 131 | } 132 | -------------------------------------------------------------------------------- /src/ui/app.css: -------------------------------------------------------------------------------- 1 | .ms--app { 2 | display: flex; 3 | flex: 1 0 100%; 4 | flex-direction: column; 5 | min-height: inherit; 6 | justify-content: center; 7 | } 8 | 9 | .ms--app__board-selector { 10 | --g: var(--spacing-03); 11 | --r: calc(var(--g) + env(safe-area-inset-right)); 12 | position: absolute; 13 | bottom: calc(var(--g) + env(safe-area-inset-bottom)); 14 | right: var(--r); 15 | width: 300px; 16 | max-width: calc(100% - var(--r) - calc(var(--g) + env(safe-area-inset-left))); 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/app.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Board } from "./board"; 3 | import { BoardSelector } from "./board-selector"; 4 | import { presets } from "./presets"; 5 | import { scope } from "../lib/utils"; 6 | 7 | function App() { 8 | let [board, setBoard] = React.useState(presets.Beginner); 9 | return ( 10 |
11 | 16 | 17 |
18 | ); 19 | } 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /src/ui/board-selector.css: -------------------------------------------------------------------------------- 1 | .ms--board-selector { 2 | z-index: 1; 3 | } 4 | 5 | .ms--board-selector__header {} 6 | 7 | .ms--board-selector__legend { 8 | white-space: nowrap; 9 | overflow: hidden; 10 | text-overflow: ellipsis; 11 | font-weight: bold; 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/board-selector.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import cx from "clsx"; 3 | import { presets } from "./presets"; 4 | import { scope } from "../lib/utils"; 5 | import { 6 | WindowsWindow, 7 | WindowsWindowBody, 8 | WindowsWindowHeader, 9 | WindowsCloseButton, 10 | } from "./windows-ui"; 11 | import { Radio, RadioGroup, RadioInput, RadioLabel } from "./radio"; 12 | 13 | const BoardSelector = ({ className, board, onPresetSelect: onBoardSelect }) => { 14 | return ( 15 | 16 |
17 | 18 | Presets 19 | 24 | 25 | 26 | 27 | { 31 | onBoardSelect(presets[value]); 32 | }} 33 | > 34 | {Object.keys(presets).map((key) => { 35 | return ( 36 |
37 | 38 | 39 | {key} 40 | 41 |
42 | ); 43 | })} 44 |
45 |
46 |
47 |
48 | ); 49 | }; 50 | 51 | export { BoardSelector }; 52 | -------------------------------------------------------------------------------- /src/ui/board.css: -------------------------------------------------------------------------------- 1 | .ms--board { 2 | display: flex; 3 | justify-content: center; 4 | } 5 | 6 | .ms--board__header-wrapper { 7 | margin-bottom: var(--spacing-02); 8 | } 9 | 10 | .ms--board__header { 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | gap: var(--spacing-02); 15 | padding: var(--spacing-02); 16 | } 17 | 18 | .ms--board__menu { 19 | display: flex; 20 | align-items: center; 21 | justify-content: flex-start; 22 | gap: var(--spacing-03); 23 | padding: var(--spacing-02) var(--spacing-02); 24 | line-height: 1; 25 | 26 | & > *:first-letter { 27 | text-decoration: underline; 28 | text-underline-offset: 1px; 29 | } 30 | } 31 | 32 | .ms--board__grid-wrapper { 33 | padding: var(--spacing-02); 34 | } 35 | 36 | .ms--board__grid { 37 | --cell-size: 24px; 38 | /* 39 | TODO: In an ideal world, we could use CSS grid because this is, well, it's a 40 | freaking grid. But we need "row" elements when using role="grid", which means 41 | we'd need `display: contents` to not be a buggy mess for various assistive 42 | devices (cough cough VoiceOver + Safari cough). If this ever gets properly 43 | addressed it'd be nice to just do it that way, but in the mean time we'll just 44 | use flex and set the cell sizes on the cell elements directly. 45 | 46 | display: flex; grid-template-columns: repeat(var(--columns), 47 | var(--cell-size)); grid-template-rows: repeat(var(--rows), var(--cell-size)); 48 | */ 49 | display: flex; 50 | flex: 0 0 100%; 51 | 52 | width: fit-content; 53 | margin: auto; 54 | } 55 | 56 | .ms--board__row { 57 | /* display: contents; */ 58 | } 59 | 60 | .ms--board__cell { 61 | display: flex; 62 | flex: 1 1 100%; 63 | border-bottom: 1px solid var(--color-black); 64 | border-right: 1px solid var(--color-black); 65 | width: var(--cell-size); 66 | height: var(--cell-size); 67 | 68 | &:where([data-revealed]) { 69 | border-style: dotted; 70 | } 71 | } 72 | 73 | .ms--board__cell-button { 74 | display: flex; 75 | justify-content: center; 76 | align-items: center; 77 | font-weight: bold; 78 | cursor: default; 79 | user-select: none; 80 | width: 100%; 81 | height: 100%; 82 | padding: 0; 83 | 84 | &:where([data-revealed]), 85 | &:where([data-revealed]:active) { 86 | background-color: var(--color-gray-07); 87 | border-width: 1px; 88 | border-color: var(--color-gray-07); 89 | border-top-color: var(--color-gray-10); 90 | border-left-color: var(--color-gray-10); 91 | } 92 | } 93 | 94 | .ms--board__cell-button[data-status="exploded"] { 95 | background-color: var(--color-tile-exploded); 96 | border-color: var(--color-tile-exploded-border); 97 | } 98 | 99 | .ms--board__reset-button { 100 | padding: 0; 101 | width: 32px; 102 | height: 32px; 103 | user-select: none; 104 | font-size: 20px; 105 | } 106 | -------------------------------------------------------------------------------- /src/ui/board.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button } from "./button"; 3 | import cx from "clsx"; 4 | import { Cell } from "./cell"; 5 | import { presets } from "./presets"; 6 | import { scope } from "../lib/utils"; 7 | import { WindowsWindow, WindowsBox, WindowsWindowHeader } from "./windows-ui"; 8 | import { CountDisplay } from "./count-display"; 9 | 10 | const initialContext = { 11 | gameState: "idle", 12 | cells: [], 13 | mines: [], 14 | initialized: false, 15 | }; 16 | 17 | // type GameState = "idle" | "active" | "won" | "lost"; 18 | 19 | function reducer(context, event) { 20 | if (event.type === "RESET") { 21 | return { 22 | ...context, 23 | gameState: "idle", 24 | cells: resetCells(event.board), 25 | initialized: false, 26 | }; 27 | } 28 | 29 | switch (context.gameState) { 30 | case "idle": { 31 | switch (event.type) { 32 | case "REVEAL_CELL": { 33 | let mines = initMines({ 34 | totalMines: event.board.mines, 35 | initialCellIndex: event.index, 36 | maxMines: getMaxMines(context.cells), 37 | }); 38 | 39 | let [gameState, cells] = selectCell( 40 | event.index, 41 | initCells(event.board, mines), 42 | mines, 43 | event.board, 44 | context.gameState 45 | ); 46 | 47 | return { 48 | ...context, 49 | gameState, 50 | cells, 51 | mines, 52 | initialized: true, 53 | }; 54 | } 55 | case "MARK_CELL": { 56 | let cells = [...context.cells]; 57 | let cell = cells[event.index]; 58 | 59 | if (!cell) { 60 | throw new Error( 61 | "Invalid index when marking the cell. Something weird happened!" 62 | ); 63 | } 64 | 65 | cells[event.index] = toggleCellFlags(cell); 66 | 67 | return { 68 | ...context, 69 | gameState: "active", 70 | cells, 71 | }; 72 | } 73 | } 74 | } 75 | case "active": { 76 | switch (event.type) { 77 | case "REVEAL_CELL": { 78 | let mines = context.mines; 79 | let currentCells = context.cells; 80 | let currentState = context.gameState; 81 | 82 | // The user can begin the game befopre initializing the board. For 83 | // example, they may start by first flagging a cell, which will start 84 | // the timer and enter into an active state. This doesn't really make 85 | // sense as a strategic move, but in that case we'll check to see that 86 | // we actually have a board and mines initialized before revealing the 87 | // cell (because all cells are still empty at this point) 88 | if (!context.initialized) { 89 | mines = initMines({ 90 | totalMines: event.board.mines, 91 | initialCellIndex: event.index, 92 | maxMines: getMaxMines(context.cells), 93 | }); 94 | currentCells = addMinesToCells(currentCells, event.board, mines); 95 | } 96 | 97 | let [gameState, cells] = selectCell( 98 | event.index, 99 | currentCells, 100 | mines, 101 | event.board, 102 | currentState 103 | ); 104 | return { 105 | ...context, 106 | gameState, 107 | cells, 108 | mines, 109 | initialized: true, 110 | }; 111 | } 112 | case "REVEAL_ADJACENT_CELLS": { 113 | let cells = [...context.cells]; 114 | let cell = cells[event.index]; 115 | if (cell.adjacentMineCount <= 0) { 116 | return context; 117 | } 118 | 119 | let markCount = 0; 120 | let cellsToReveal = []; 121 | let board = event.board; 122 | let mines = context.mines; 123 | let gameState = context.gameState; 124 | 125 | for (let idx of cell.adjacentIndexMatrix) { 126 | if (idx == null) continue; 127 | let cell = cells[idx]; 128 | if (cell.status === "flagged") { 129 | markCount++; 130 | } 131 | if (cell && cell.status === "hidden") { 132 | cellsToReveal.push(idx); 133 | } 134 | } 135 | if (markCount >= cell.adjacentMineCount) { 136 | for (let cell of cellsToReveal) { 137 | [gameState, cells] = selectCell( 138 | cell, 139 | cells, 140 | mines, 141 | board, 142 | gameState 143 | ); 144 | } 145 | return { 146 | ...context, 147 | gameState, 148 | cells, 149 | }; 150 | } 151 | return context; 152 | } 153 | case "MARK_CELL": { 154 | let cells = [...context.cells]; 155 | let cell = cells[event.index]; 156 | 157 | if (!cell) { 158 | throw new Error( 159 | "Invalid index when marking the cell. Something weird happened!" 160 | ); 161 | } 162 | 163 | cells[event.index] = toggleCellFlags(cell); 164 | 165 | return { 166 | ...context, 167 | cells, 168 | }; 169 | } 170 | } 171 | } 172 | case "won": { 173 | switch (event.type) { 174 | case "MARK_REMAINING_MINES": { 175 | let cellsToMark = context.cells.reduce((cells, cell, index) => { 176 | if (cell.status === "hidden" || cell.status === "question") { 177 | return [...cells, index]; 178 | } 179 | return cells; 180 | }, []); 181 | 182 | if (cellsToMark.length < 1) { 183 | return context; 184 | } 185 | 186 | let cells = [...context.cells]; 187 | for (let index of cellsToMark) { 188 | let cell = cells[index]; 189 | if (!cell) { 190 | throw new Error( 191 | "Invalid index when marking the cell. Something weird happened!" 192 | ); 193 | } 194 | cells[index] = flagCell(cell); 195 | } 196 | return { 197 | ...context, 198 | cells, 199 | }; 200 | } 201 | } 202 | } 203 | } 204 | return context; 205 | } 206 | 207 | // interface BoardConfig { 208 | // rows: number; 209 | // columns: number; 210 | // mines: number; 211 | // } 212 | 213 | const Board = ({ board = presets.Beginner }) => { 214 | let [{ gameState, cells, mines }, send] = React.useReducer( 215 | reducer, 216 | initialContext, 217 | function getInitialContext(ctx) { 218 | return { 219 | ...ctx, 220 | cells: createCells(board), 221 | }; 222 | } 223 | ); 224 | 225 | let [timeElapsed, resetTimer] = useTimer(gameState); 226 | 227 | React.useEffect(() => { 228 | if (gameState === "won") { 229 | send({ 230 | type: "MARK_REMAINING_MINES", 231 | board: board, 232 | }); 233 | } 234 | }, [gameState, board]); 235 | 236 | let reset = React.useCallback(() => { 237 | resetTimer(); 238 | send({ type: "RESET", board }); 239 | }, [board, resetTimer]); 240 | 241 | let firstRenderRef = React.useRef(true); 242 | React.useEffect(() => { 243 | if (firstRenderRef.current) { 244 | firstRenderRef.current = false; 245 | } else { 246 | reset(); 247 | } 248 | }, [board, reset]); 249 | 250 | let remainingMineCount = getRemainingMineCount(cells, board.mines); 251 | 252 | let rowArray = React.useMemo( 253 | () => Array(board.rows).fill(null), 254 | [board.rows] 255 | ); 256 | let getColumnArray = React.useCallback( 257 | (rowIndex) => 258 | cells.slice( 259 | board.columns * rowIndex, 260 | board.columns * rowIndex + board.columns 261 | ), 262 | [board.columns, cells] 263 | ); 264 | 265 | return ( 266 |
267 | 268 | Minesweeper 269 |
270 | Game 271 | Help 272 |
273 | 274 | 279 |
280 | 281 | 285 | 286 | 287 | 288 | 292 | 293 |
294 |
295 | 306 | {rowArray.map((_, rowIndex) => { 307 | return ( 308 |
309 | {getColumnArray(rowIndex).map((cell, i) => { 310 | let hasMine = mines.includes(cell.index); 311 | return ( 312 | { 317 | send({ 318 | type: "MARK_CELL", 319 | index: cell.index, 320 | board: board, 321 | }); 322 | }} 323 | handleSingleCellSelect={() => { 324 | send({ 325 | type: "REVEAL_CELL", 326 | index: cell.index, 327 | board: board, 328 | }); 329 | }} 330 | handleAdjacentCellsSelect={() => { 331 | send({ 332 | type: "REVEAL_ADJACENT_CELLS", 333 | index: cell.index, 334 | board: board, 335 | }); 336 | }} 337 | > 338 | 343 | 344 | ); 345 | })} 346 |
347 | ); 348 | })} 349 |
350 |
351 |
352 |
353 | ); 354 | }; 355 | 356 | const GridCell = ({ 357 | children, 358 | gameState, 359 | handleMark, 360 | handleSingleCellSelect, 361 | handleAdjacentCellsSelect, 362 | status, 363 | }) => { 364 | let gameIsOver = gameState === "won" || gameState === "lost"; 365 | let isRevealed = status === "exploded" || status === "revealed"; 366 | let ref = React.useRef(null); 367 | 368 | return ( 369 |
374 | 443 |
444 | ); 445 | }; 446 | GridCell.displayName = "GridCell"; 447 | 448 | const GridCellIcon = ({ status, hasMine, mineValue }) => { 449 | let value = ""; 450 | switch (status) { 451 | case "exploded": 452 | value = "💥"; 453 | break; 454 | case "flagged": 455 | value = "🚩"; 456 | break; 457 | case "question": 458 | value = "❓"; 459 | break; 460 | case "revealed": 461 | value = hasMine ? "💣" : mineValue ? String(mineValue) : ""; 462 | break; 463 | } 464 | return ( 465 | 475 | {value} 476 | 477 | ); 478 | }; 479 | 480 | const ResetButton = ({ handleReset, gameState }) => { 481 | return ( 482 | 498 | ); 499 | }; 500 | 501 | /** 502 | * @param {{ 503 | * totalMines: number; 504 | * maxMines: number; 505 | * initialCellIndex: number; 506 | * }} args 507 | * @returns {number[]} 508 | */ 509 | function initMines({ totalMines, maxMines, initialCellIndex }) { 510 | let mines = []; 511 | let minesToAssign = Array(totalMines).fill(null); 512 | let randomCellIndex; 513 | do { 514 | randomCellIndex = Math.floor(Math.random() * maxMines); 515 | if ( 516 | mines.indexOf(randomCellIndex) === -1 && 517 | initialCellIndex !== randomCellIndex 518 | ) { 519 | minesToAssign.pop(); 520 | mines.push(randomCellIndex); 521 | } 522 | } while (minesToAssign.length); 523 | return mines; 524 | } 525 | 526 | /** 527 | * @param {Cell[]} cells 528 | * @param {number} totalMines 529 | * @returns {number} 530 | */ 531 | function getRemainingMineCount(cells, totalMines) { 532 | return ( 533 | totalMines - 534 | cells.reduce((prev, cur) => { 535 | return cur.status === "flagged" ? ++prev : prev; 536 | }, 0) 537 | ); 538 | } 539 | 540 | /** 541 | * @param {{ 542 | * rows: number; 543 | * columns: number; 544 | * mines: number; 545 | * }} board 546 | * @returns {number} 547 | */ 548 | function getCellCount(board) { 549 | return board.columns * board.rows; 550 | } 551 | 552 | /** 553 | * @param {{ 554 | * rows: number; 555 | * columns: number; 556 | * mines: number; 557 | * }} board 558 | * @param {number[]} [mines] 559 | * @returns {Cell[]} 560 | */ 561 | function createCells(board, mines) { 562 | return Array(getCellCount(board)) 563 | .fill(null) 564 | .map((_, index) => { 565 | return new Cell({ 566 | index, 567 | board, 568 | mines: mines || [], 569 | status: "hidden", 570 | }); 571 | }); 572 | } 573 | 574 | /** 575 | * @param {{ 576 | * rows: number; 577 | * columns: number; 578 | * mines: number; 579 | * }} board 580 | * @returns {Cell[]} 581 | */ 582 | function resetCells(board) { 583 | return createCells(board); 584 | } 585 | 586 | /** 587 | * @param {{ 588 | * rows: number; 589 | * columns: number; 590 | * mines: number; 591 | * }} board 592 | * @param {number[]} mines 593 | * @returns {Cell[]} 594 | */ 595 | function initCells(board, mines) { 596 | return createCells(board, mines); 597 | } 598 | 599 | /** 600 | * @param {Cell[]} cells 601 | * @param {{ 602 | * rows: number; 603 | * columns: number; 604 | * mines: number; 605 | * }} board 606 | * @param {number[]} mines 607 | * @returns {Cell[]} 608 | */ 609 | function addMinesToCells(cells, board, mines) { 610 | return Array(getCellCount(board)) 611 | .fill(null) 612 | .map((_, index) => { 613 | return new Cell({ 614 | index, 615 | board, 616 | mines: mines, 617 | status: cells[index]?.status || "hidden", 618 | }); 619 | }); 620 | } 621 | 622 | /** 623 | * @param {Cell} cell 624 | * @returns {Cell} 625 | */ 626 | function flagCell(cell) { 627 | return new Cell(cell, { 628 | status: "flagged", 629 | }); 630 | } 631 | 632 | /** 633 | * @param {Cell} cell 634 | * @returns {Cell} 635 | */ 636 | function toggleCellFlags(cell) { 637 | return new Cell(cell, { 638 | status: 639 | cell.status === "flagged" 640 | ? "question" 641 | : cell.status === "question" 642 | ? "hidden" 643 | : "flagged", 644 | }); 645 | } 646 | 647 | /** 648 | * 649 | * @param {number} cellIndex 650 | * @param {Cell[]} cells 651 | * @param {number[]} mines 652 | * @param {{ 653 | * rows: number; 654 | * columns: number; 655 | * mines: number; 656 | * }} board 657 | * @param {"idle" | "active" | "won" | "lost"} startingState 658 | * @returns {["idle" | "active" | "won" | "lost", Cell[]]} 659 | */ 660 | function selectCell(cellIndex, cells, mines, board, startingState) { 661 | let cellsCopy = [...cells]; 662 | let cell = cellsCopy[cellIndex]; 663 | if (!cell) { 664 | throw new Error( 665 | "Invalid index when selecting the cell. Something weird happened!" 666 | ); 667 | } 668 | if (cell.status === "exploded" || cell.status === "revealed") { 669 | return [startingState, cells]; 670 | } 671 | 672 | // This cell is a mine so BOOOOOOOOM 673 | if (mines.includes(cellIndex)) { 674 | // reveal all mines, then return new context because the game is over! 675 | for (let index of mines) { 676 | cellsCopy[index] = new Cell(cell, { 677 | status: index === cellIndex ? "exploded" : "revealed", 678 | }); 679 | } 680 | return ["lost", cellsCopy]; 681 | } 682 | 683 | cellsCopy[cellIndex] = new Cell(cell, { 684 | status: "revealed", 685 | }); 686 | 687 | let isComplete = 688 | cellsCopy.length - mines.length === getTotalRevealedCells(cellsCopy); 689 | 690 | let gameState; 691 | // eslint-disable-next-line no-self-assign 692 | [gameState, cellsCopy] = [isComplete ? "won" : "active", cellsCopy]; 693 | 694 | if (cell.adjacentMineCount === 0) { 695 | let adjacentCells = cell.adjacentIndexMatrix.filter((idx) => idx != null); 696 | 697 | for (let adjacentCellIndex of adjacentCells) { 698 | let adjacentCell = cellsCopy[adjacentCellIndex]; 699 | if (adjacentCell.adjacentMineCount >= 0) { 700 | [gameState, cellsCopy] = selectCell( 701 | adjacentCellIndex, 702 | cellsCopy, 703 | mines, 704 | board, 705 | gameState 706 | ); 707 | } 708 | } 709 | } 710 | 711 | return [gameState, cellsCopy]; 712 | } 713 | 714 | /** 715 | * @param {Cell[]} cells 716 | * @returns 717 | */ 718 | function getMaxMines(cells) { 719 | return cells.length - 1; 720 | } 721 | 722 | /** 723 | * @param {Cell[]} cells 724 | * @returns {number} 725 | */ 726 | function getTotalRevealedCells(cells) { 727 | return cells.reduce((count, cell) => { 728 | if (cell.status === "revealed") { 729 | return ++count; 730 | } 731 | return count; 732 | }, 0); 733 | } 734 | 735 | /** 736 | * @param {"idle" | "active" | "won" | "lost"} gameState 737 | * @returns {[number, () => void]} 738 | */ 739 | function useTimer(gameState) { 740 | let [timeElapsed, setTimeElapsed] = React.useState(0); 741 | React.useEffect(() => { 742 | if (gameState === "active") { 743 | let id = window.setInterval(() => { 744 | setTimeElapsed((t) => (t <= 999 ? ++t : t)); 745 | }, 1000); 746 | return () => { 747 | window.clearInterval(id); 748 | }; 749 | } 750 | }, [gameState]); 751 | const reset = React.useCallback(() => { 752 | setTimeElapsed(0); 753 | }, []); 754 | return [timeElapsed, reset]; 755 | } 756 | 757 | export { Board }; 758 | -------------------------------------------------------------------------------- /src/ui/button.css: -------------------------------------------------------------------------------- 1 | .ms--button { 2 | --border-size: var(--border-2); 3 | appearance: none; 4 | padding: var(--spacing-01) var(--spacing-02); 5 | display: inline-flex; 6 | align-items: center; 7 | justify-content: center; 8 | text-align: center; 9 | color: var(--color-black); 10 | border: var(--border-size) solid var(--color-gray-05); 11 | border-bottom-color: var(--color-gray-09); 12 | border-right-color: var(--color-gray-09); 13 | background: var(--color-gray-07); 14 | 15 | &:where(:active:not([data-meta-pressed])) { 16 | background-color: var(--color-gray-08); 17 | border-color: var(--color-gray-05); 18 | border-top-color: var(--color-gray-09); 19 | border-left-color: var(--color-gray-09); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/button.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import cx from "clsx"; 3 | import { scope } from "../lib/utils"; 4 | 5 | const Button = React.forwardRef( 6 | ( 7 | { 8 | children, 9 | type: buttonType = "button", 10 | className, 11 | onPointerDown, 12 | onPointerUp, 13 | ...props 14 | }, 15 | forwardedRef 16 | ) => { 17 | let [metaPress, setMetaPress] = React.useState(false); 18 | React.useEffect(() => { 19 | if (metaPress) { 20 | let listener = () => { 21 | setMetaPress(false); 22 | }; 23 | window.addEventListener("pointerup", listener); 24 | return () => { 25 | window.removeEventListener("pointerup", listener); 26 | }; 27 | } 28 | }, [metaPress]); 29 | 30 | return ( 31 | 50 | ); 51 | } 52 | ); 53 | 54 | Button.displayName = "Button"; 55 | 56 | export { Button }; 57 | -------------------------------------------------------------------------------- /src/ui/cell.js: -------------------------------------------------------------------------------- 1 | export class Cell { 2 | constructor(cell, data) { 3 | let cellData; 4 | if (cell instanceof Cell) { 5 | cellData = { 6 | index: cell.index, 7 | board: cell.__board, 8 | mines: cell.__mines, 9 | status: cell.status, 10 | ...data, 11 | }; 12 | } else { 13 | cellData = cell; 14 | } 15 | 16 | this.__board = cellData.board; 17 | this.__mines = cellData.mines; 18 | this.status = cellData.status || "hidden"; 19 | this.index = cellData.index; 20 | this.column = getCellColumn(this.index, this.__board); 21 | this.row = getCellRow(this.index, this.__board); 22 | } 23 | 24 | hasMine() { 25 | return this.__mines ? this.__mines.includes(this.index) : false; 26 | } 27 | 28 | get adjacentIndexMatrix() { 29 | let board = this.__board; 30 | let row = this.row; 31 | let column = this.column; 32 | // prettier-ignore 33 | return [ 34 | 35 | /* left center right */ 36 | /* 1 */ [ row - 1, column - 1 ], [ row - 1, column ], [ row - 1, column + 1 ], 37 | 38 | /* 2 */ [ row, column - 1 ], /* 💣 */ [ row, column + 1 ], 39 | 40 | /* 3 */ [ row + 1, column - 1 ], [ row + 1, column ], [ row + 1, column + 1 ], 41 | 42 | ].map(([row, column]) => { 43 | return getIndexByRowAndColumn({ row, column, board }) 44 | }) 45 | } 46 | 47 | get adjacentMineCount() { 48 | return this.hasMine() 49 | ? -1 50 | : this.adjacentIndexMatrix.reduce((count, index) => { 51 | return this.__mines && index != null 52 | ? this.__mines.includes(index) 53 | ? ++count 54 | : count 55 | : count; 56 | }, 0); 57 | } 58 | } 59 | 60 | function getCellRow(index, board) { 61 | return Math.floor(index / board.columns); 62 | } 63 | 64 | function getCellColumn(index, board) { 65 | return index % board.columns; 66 | } 67 | 68 | function getIndexByRowAndColumn({ board, row, column }) { 69 | if ( 70 | row < 0 || 71 | column < 0 || 72 | row > board.rows - 1 || 73 | column > board.columns - 1 74 | ) { 75 | return null; 76 | } 77 | return row * board.columns + column; 78 | } 79 | -------------------------------------------------------------------------------- /src/ui/count-display.css: -------------------------------------------------------------------------------- 1 | .ms--count-display { 2 | --y-offset: 4px; 3 | --x-offset: 2px; 4 | position: relative; 5 | font-family: var(--font-clock); 6 | font-size: 28px; 7 | padding: var(--x-offset) var(--y-offset); 8 | background: var(--color-black); 9 | color: var(--color-clock-text); 10 | width: calc(3ch + var(--y-offset) * 2); 11 | text-align: right; 12 | user-select: none; 13 | line-height: 1; 14 | 15 | /* minor adjustment because fonts goes brrrr */ 16 | padding-bottom: calc(var(--x-offset) - 1px); 17 | 18 | /* Creates the dimmed number effect in the background */ 19 | &::before { 20 | content: "888"; 21 | position: absolute; 22 | opacity: 0.35; 23 | top: var(--x-offset); 24 | left: var(--y-offset); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/count-display.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import cx from "clsx"; 3 | import { scope } from "../lib/utils"; 4 | 5 | function CountDisplay({ count, className }) { 6 | let countString = String(Math.max(Math.min(count, 999), -99)); 7 | return ( 8 |
{countString}
9 | ); 10 | } 11 | 12 | export { CountDisplay }; 13 | -------------------------------------------------------------------------------- /src/ui/counter.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | class Counter extends React.Component { 4 | state = { 5 | count: this.props.initialCount ?? 0, 6 | }; 7 | 8 | constructor(props) { 9 | super(props); 10 | this.increment = this.increment.bind(this); 11 | this.decrement = this.decrement.bind(this); 12 | } 13 | 14 | shouldComponentUpdate(nextProps, nextState) { 15 | return shallowCompare(this, nextProps, nextState); 16 | } 17 | 18 | increment() { 19 | this.setState(({ count: prevCount }) => ({ 20 | count: prevCount + 1, 21 | })); 22 | } 23 | 24 | decrement() { 25 | this.setState(({ count: prevCount }) => ({ 26 | count: prevCount - 1, 27 | })); 28 | } 29 | 30 | render() { 31 | return ( 32 |
33 | 36 | {this.state.count} 37 | 40 |
41 | ); 42 | } 43 | } 44 | 45 | export { Counter }; 46 | 47 | function shallowCompare(instance, nextProps, nextState) { 48 | return ( 49 | !shallowEqual(instance.props, nextProps) || 50 | !shallowEqual(instance.state, nextState) 51 | ); 52 | } 53 | 54 | let hasOwnProperty = Object.prototype.hasOwnProperty; 55 | 56 | function shallowEqual(objA, objB) { 57 | if (is(objA, objB)) { 58 | return true; 59 | } 60 | 61 | if ( 62 | typeof objA !== "object" || 63 | objA === null || 64 | typeof objB !== "object" || 65 | objB === null 66 | ) { 67 | return false; 68 | } 69 | 70 | let keysA = Object.keys(objA); 71 | let keysB = Object.keys(objB); 72 | 73 | if (keysA.length !== keysB.length) { 74 | return false; 75 | } 76 | 77 | for (let i = 0; i < keysA.length; i++) { 78 | if ( 79 | !hasOwnProperty.call(objB, keysA[i]) || 80 | !is(objA[keysA[i]], objB[keysA[i]]) 81 | ) { 82 | return false; 83 | } 84 | } 85 | 86 | return true; 87 | } 88 | 89 | function is(x, y) { 90 | if (x === y) { 91 | return x !== 0 || y !== 0 || 1 / x === 1 / y; 92 | } 93 | // eslint-disable-next-line no-self-compare 94 | return x !== x && y !== y; 95 | } 96 | -------------------------------------------------------------------------------- /src/ui/icons.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const IconWindowsClose = React.forwardRef(({ color, ...props }, ref) => { 4 | return ( 5 | 11 | 18 | 19 | ); 20 | }); 21 | 22 | IconWindowsClose.displayName = "IconWindowsClose"; 23 | -------------------------------------------------------------------------------- /src/ui/presets.js: -------------------------------------------------------------------------------- 1 | export const presets = { 2 | Baby: { 3 | name: "Baby", 4 | rows: 4, 5 | columns: 4, 6 | mines: 3, 7 | }, 8 | Beginner: { 9 | name: "Beginner", 10 | rows: 9, 11 | columns: 9, 12 | mines: 10, 13 | }, 14 | Intermediate: { 15 | name: "Intermediate", 16 | rows: 16, 17 | columns: 16, 18 | mines: 40, 19 | }, 20 | Expert: { 21 | name: "Expert", 22 | rows: 16, 23 | columns: 30, 24 | mines: 99, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/ui/radio.css: -------------------------------------------------------------------------------- 1 | .radio__input {} 2 | .radio__label {} 3 | -------------------------------------------------------------------------------- /src/ui/radio.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import cx from "clsx"; 3 | import { scope } from "../lib/utils"; 4 | 5 | const RadioGroupContext = React.createContext(null); 6 | 7 | const RadioGroup = ({ children, checked, onChange, name }) => { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | }; 14 | 15 | const RadioContext = React.createContext(null); 16 | 17 | const Radio = ({ children, id, value }) => { 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | }; 24 | 25 | const RadioInput = React.forwardRef( 26 | ({ children, className, ...props }, forwardedRef) => { 27 | let { id, value } = React.useContext(RadioContext); 28 | let { checked, onChange, name } = React.useContext(RadioGroupContext); 29 | 30 | return ( 31 | { 40 | props.onChange?.(event); 41 | if (!event.defaultPrevented) { 42 | onChange(event.target.value); 43 | } 44 | }} 45 | checked={checked === value} 46 | > 47 | {children} 48 | 49 | ); 50 | } 51 | ); 52 | 53 | RadioInput.displayName = "RadioInput"; 54 | 55 | const RadioLabel = React.forwardRef( 56 | ({ children, className, ...props }, forwardedRef) => { 57 | let { id } = React.useContext(RadioContext); 58 | return ( 59 | 67 | ); 68 | } 69 | ); 70 | 71 | RadioLabel.displayName = "RadioLabel"; 72 | 73 | export { RadioGroup, Radio, RadioInput, RadioLabel }; 74 | -------------------------------------------------------------------------------- /src/ui/ui.css: -------------------------------------------------------------------------------- 1 | @import "./radio.css"; 2 | @import "./button.css"; 3 | 4 | @import "./windows-ui.css"; 5 | @import "./count-display.css"; 6 | @import "./board-selector.css"; 7 | @import "./board.css"; 8 | @import "./app.css"; 9 | -------------------------------------------------------------------------------- /src/ui/windows-ui.css: -------------------------------------------------------------------------------- 1 | .ms--windows--box { 2 | --border-size: var(--border-2); 3 | border: var(--border-size) solid var(--color-gray-10); 4 | border-top-color: var(--color-gray-05); 5 | border-left-color: var(--color-gray-05); 6 | background: var(--color-gray-07); 7 | 8 | &:where([data-inset]) { 9 | border-color: var(--color-gray-05); 10 | border-top-color: var(--color-gray-10); 11 | border-left-color: var(--color-gray-10); 12 | } 13 | 14 | &:where([data-depth="2"]) { 15 | --border-size: var(--border-2); 16 | } 17 | 18 | &:where([data-depth="3"]) { 19 | --border-size: var(--border-3); 20 | } 21 | 22 | &:where([data-depth="4"]) { 23 | --border-size: var(--border-4); 24 | } 25 | } 26 | 27 | .ms--windows--window { 28 | padding: var(--border-2); 29 | } 30 | 31 | .ms--windows--window-body { 32 | padding: var(--spacing-03); 33 | padding-top: var(--spacing-05); 34 | } 35 | 36 | .ms--windows--window-header { 37 | --button-size: 24px; 38 | --p: var(--spacing-01); 39 | display: flex; 40 | justify-content: space-between; 41 | align-items: center; 42 | gap: var(--spacing-02); 43 | flex: 0 1 100%; 44 | background: linear-gradient(to right, #020071, #1784d6); 45 | padding: var(--p); 46 | height: calc(var(--button-size) + var(--p) * 2); 47 | line-height: 1; 48 | color: var(--color-white); 49 | white-space: nowrap; 50 | overflow: hidden; 51 | text-overflow: ellipsis; 52 | font-weight: bold; 53 | } 54 | 55 | .ms--windows--close-button { 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | width: var(--button-size); 60 | height: var(--button-size); 61 | padding: 0; 62 | 63 | @nest :where(& > *) { 64 | width: calc(var(--button-size) - var(--border-size) * 2); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ui/windows-ui.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import cx from "clsx"; 3 | import { Button } from "./button"; 4 | import { IconWindowsClose } from "./icons"; 5 | import { scope } from "../lib/utils"; 6 | 7 | const WindowsBox = React.forwardRef((props, forwardedRef) => { 8 | let { children, className, inset, depth = 2, ...domProps } = props; 9 | return ( 10 |
17 | {children} 18 |
19 | ); 20 | }); 21 | 22 | WindowsBox.displayName = "WindowsBox"; 23 | 24 | const WindowsWindow = React.forwardRef( 25 | ({ children, className, ...props }, forwardedRef) => { 26 | return ( 27 | 33 | {children} 34 | 35 | ); 36 | } 37 | ); 38 | WindowsWindow.displayName = "WindowsWindow"; 39 | 40 | const WindowsWindowHeader = React.forwardRef( 41 | ({ children, className, ...props }, forwardedRef) => { 42 | return ( 43 |
48 | {children} 49 |
50 | ); 51 | } 52 | ); 53 | WindowsWindowHeader.displayName = "WindowsWindowHeader"; 54 | 55 | const WindowsWindowBody = React.forwardRef( 56 | ({ children, className, ...props }, forwardedRef) => { 57 | return ( 58 |
63 | {children} 64 |
65 | ); 66 | } 67 | ); 68 | WindowsWindowBody.displayName = "WindowsWindowBody"; 69 | 70 | const WindowsCloseButton = React.forwardRef(({ className, ...props }, ref) => { 71 | return ( 72 | 79 | ); 80 | }); 81 | WindowsCloseButton.displayName = "WindowsCloseButton"; 82 | 83 | export { 84 | WindowsBox, 85 | WindowsWindow, 86 | WindowsWindowBody, 87 | WindowsWindowHeader, 88 | WindowsCloseButton, 89 | }; 90 | --------------------------------------------------------------------------------