├── .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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------