├── .eslintignore
├── .eslintrc.cjs
├── .github
└── workflows
│ ├── codeql.yml
│ ├── continuous-integration.yml
│ ├── dependency-review.yml
│ └── static.yml
├── .gitignore
├── .prettierrc.cjs
├── .stylelintrc.cjs
├── README.md
├── index.html
├── package.json
├── public
└── vite.svg
├── src
├── App.scss
├── App.tsx
├── components
│ ├── Calculator
│ │ ├── Calculator.scss
│ │ ├── Calculator.test.tsx
│ │ ├── Calculator.tsx
│ │ └── index.ts
│ ├── CalculatorDisplay
│ │ ├── CalculatorDisplay.scss
│ │ ├── CalculatorDisplay.test.tsx
│ │ ├── CalculatorDisplay.tsx
│ │ └── index.ts
│ └── CalculatorKey
│ │ ├── CalculatorKey.scss
│ │ ├── CalculatorKey.test.tsx
│ │ ├── CalculatorKey.tsx
│ │ └── index.ts
├── hooks
│ ├── index.ts
│ └── useCalculator.ts
├── index.scss
├── main.tsx
├── reducer
│ ├── index.ts
│ └── reducer.ts
├── setupTests.ts
├── types
│ └── index.ts
├── utils
│ ├── helper.test.ts
│ ├── helpers.ts
│ └── index.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── yarn.lock
/.eslintignore:
--------------------------------------------------------------------------------
1 | public
2 | dist
3 | setupTests.ts
4 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | },
6 | extends: ['react-app', 'plugin:react/recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
7 | overrides: [],
8 | parser: '@typescript-eslint/parser',
9 | parserOptions: {
10 | ecmaVersion: 'latest',
11 | sourceType: 'module',
12 | project: './tsconfig.json',
13 | },
14 | plugins: ['react', '@typescript-eslint', 'prettier'],
15 | rules: {
16 | 'react/react-in-jsx-scope': 0,
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ main ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ main ]
20 | schedule:
21 | - cron: '33 19 * * 1'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'javascript', 'typescript' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
38 |
39 | steps:
40 | - uses: actions/checkout@v3
41 | - uses: actions/setup-node@v3
42 | with:
43 | node-version: 14
44 |
45 | # Initializes the CodeQL tools for scanning.
46 | - name: Initialize CodeQL
47 | uses: github/codeql-action/init@v2
48 | with:
49 | languages: ${{ matrix.language }}
50 | # If you wish to specify custom queries, you can do so here or in a config file.
51 | # By default, queries listed here will override any specified in a config file.
52 | # Prefix the list here with "+" to use these queries and those in the config file.
53 |
54 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
55 | # queries: security-extended,security-and-quality
56 |
57 |
58 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
59 | # If this step fails, then you should remove it and run the build manually (see below)
60 | - name: Autobuild
61 | uses: github/codeql-action/autobuild@v2
62 |
63 | # ℹ️ Command-line programs to run using the OS shell.
64 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
65 |
66 | # If the Autobuild fails above, remove it and uncomment the following three lines.
67 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
68 |
69 | # - run: |
70 | # echo "Run, Build Application using script"
71 | # ./location_of_script_within_repo/buildscript.sh
72 |
73 | - name: Perform CodeQL Analysis
74 | uses: github/codeql-action/analyze@v2
75 |
76 |
--------------------------------------------------------------------------------
/.github/workflows/continuous-integration.yml:
--------------------------------------------------------------------------------
1 | name: react-calculator-app
2 |
3 | on:
4 | pull_request:
5 | branches: [ main ]
6 |
7 | jobs:
8 | continuous-integration:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v3
12 | - uses: actions/setup-node@v3
13 | with:
14 | node-version: 16.x
15 |
16 | - name: Install dependencies
17 | run: |
18 | yarn install --frozen-lockfile
19 |
20 | - name: Run Continuous Integration
21 | run: |
22 | yarn ci
23 |
--------------------------------------------------------------------------------
/.github/workflows/dependency-review.yml:
--------------------------------------------------------------------------------
1 | # Dependency Review Action
2 | #
3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
4 | #
5 | # Source repository: https://github.com/actions/dependency-review-action
6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
7 | name: 'Dependency Review'
8 | on:
9 | pull_request:
10 | branches: [ main ]
11 |
12 | permissions:
13 | contents: read
14 |
15 | jobs:
16 | dependency-review:
17 | runs-on: ubuntu-latest
18 | steps:
19 | - name: 'Checkout Repository'
20 | uses: actions/checkout@v3
21 | - name: 'Dependency Review'
22 | uses: actions/dependency-review-action@v2
23 |
--------------------------------------------------------------------------------
/.github/workflows/static.yml:
--------------------------------------------------------------------------------
1 | # Simple workflow for deploying static content to GitHub Pages
2 | name: Deploy static content to Pages
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ['main']
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow one concurrent deployment
19 | concurrency:
20 | group: 'pages'
21 | cancel-in-progress: true
22 |
23 | jobs:
24 | # Single deploy job since we're just deploying
25 | deploy:
26 | environment:
27 | name: github-pages
28 | url: ${{ steps.deployment.outputs.page_url }}
29 | runs-on: ubuntu-latest
30 | steps:
31 | - name: Checkout
32 | uses: actions/checkout@v3
33 | - name: Set up Node
34 | uses: actions/setup-node@v3
35 | with:
36 | node-version: 18
37 | cache: 'npm'
38 | - name: Install dependencies
39 | run: npm install
40 | - name: Build
41 | run: npm run build
42 | - name: Setup Pages
43 | uses: actions/configure-pages@v3
44 | - name: Upload artifact
45 | uses: actions/upload-pages-artifact@v1
46 | with:
47 | # Upload dist repository
48 | path: './dist'
49 | - name: Deploy to GitHub Pages
50 | id: deployment
51 | uses: actions/deploy-pages@v1
52 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | bracketSpacing: true,
4 | semi: true,
5 | tabWidth: 2,
6 | printWidth: 140,
7 | arrowParens: 'always',
8 | quoteProps: 'preserve',
9 | trailingComma: 'all',
10 | overrides: [
11 | {
12 | files: '*.jsx',
13 | options: {
14 | printWidth: 120,
15 | },
16 | },
17 | {
18 | files: '*.tsx',
19 | options: {
20 | printWidth: 120,
21 | },
22 | },
23 | ],
24 | };
25 |
--------------------------------------------------------------------------------
/.stylelintrc.cjs:
--------------------------------------------------------------------------------
1 | const bemClass = /^([a-z0-9\\-]{2,})(__[a-z0-9\\-]{2,})?(--[a-z0-9\\-]{2,})?$/;
2 |
3 | module.exports = {
4 | plugins: ['stylelint-scss', 'stylelint-prettier'],
5 | customSyntax: 'postcss-scss',
6 | rules: {
7 | 'prettier/prettier': true,
8 |
9 | // General / Sheet
10 | 'no-duplicate-at-import-rules': true,
11 | 'no-duplicate-selectors': true,
12 | 'selector-max-universal': 1,
13 | 'max-nesting-depth': 4,
14 |
15 | 'scss/at-import-no-partial-leading-underscore': true,
16 |
17 | // Declaration block
18 | 'declaration-block-no-duplicate-properties': true,
19 | 'declaration-block-no-shorthand-property-overrides': true,
20 |
21 | // Selector
22 | 'selector-class-pattern': [bemClass, { resolveNestedSelectors: true }],
23 | 'selector-pseudo-class-no-unknown': [
24 | true,
25 | {
26 | ignorePseudoClasses: 'global',
27 | },
28 | ],
29 | 'selector-pseudo-element-no-unknown': true,
30 | 'selector-type-no-unknown': true,
31 | 'selector-no-vendor-prefix': true,
32 | 'selector-pseudo-element-colon-notation': 'single',
33 | 'scss/selector-no-redundant-nesting-selector': true,
34 |
35 | // Media
36 | 'media-feature-name-no-unknown': true,
37 | 'media-feature-name-no-vendor-prefix': true,
38 |
39 | // At-rule
40 | 'scss/at-rule-no-unknown': true,
41 | 'at-rule-no-vendor-prefix': true,
42 |
43 | // Properties
44 | 'property-no-unknown': true,
45 | 'shorthand-property-no-redundant-values': true,
46 |
47 | // Values
48 | 'number-max-precision': 4,
49 | 'value-no-vendor-prefix': true,
50 | 'unit-no-unknown': true,
51 | 'length-zero-no-unit': true,
52 |
53 | // Colors
54 | 'color-no-invalid-hex': true,
55 |
56 | // Fonts
57 | 'font-family-no-duplicate-names': true,
58 | 'font-family-no-missing-generic-family-keyword': true,
59 | 'font-family-name-quotes': 'always-unless-keyword',
60 |
61 | // Function
62 | 'function-linear-gradient-no-nonstandard-direction': true,
63 | // 'function-calc-no-invalid': true,
64 | // Comment
65 | 'comment-no-empty': true,
66 |
67 | // Operators
68 | 'scss/operator-no-unspaced': true,
69 | },
70 | };
71 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ### Live Demo
2 | Click [here](https://kaihotz.github.io/React-Calculator-App)
3 |
4 | ## Getting Started
5 |
6 | There are two methods for getting started with this repo.
7 |
8 | ### Familiar with Git?
9 | ```
10 | > git clone git@github.com:KaiHotz/React-Calculator-App.git
11 | > cd React-Calculator-App
12 | > yarn install
13 | > yarn dev
14 | > follow the instructions
15 | ```
16 |
17 | ### Not Familiar with Git?
18 | Click [here](https://github.com/KaiHotz/React-Calculator-App/archive/master.zip) to download the .zip file. Extract the contents of the zip file, then open your terminal, change to the project directory, and:
19 | ```
20 | > cd React-Calculator-App
21 | > yarn install
22 | > yarn dev
23 | > follow the instructions
24 | ```
25 |
26 | ## Testing
27 |
28 | ### Resources
29 | - [@testing-library/react](https://testing-library.com/docs/react-testing-library/intro/)
30 |
31 | ### To run Tests
32 | ```
33 | > yarn test
34 | ```
35 |
36 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | react-calculator-app
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-calculator-app",
3 | "version": "4.0.0",
4 | "type": "module",
5 | "repository": "https://github.com/KaiHotz/React-Calculator-App",
6 | "homepage": "https://KaiHotz.github.io/React-Calculator-App",
7 | "author": "Kai Hotz",
8 | "license": "MIT",
9 | "private": true,
10 | "scripts": {
11 | "dev": "vite",
12 | "build": "tsc && rimraf dist && vite build",
13 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
14 | "lint:fix": "yarn run eslint:fix && yarn run stylelint:fix",
15 | "stylelint": "stylelint \"**/*.scss\"",
16 | "stylelint:fix": "stylelint \"**/*.scss\" --fix",
17 | "check-types": "tsc",
18 | "ci": "yarn lint && yarn test --watch=false",
19 | "test": "vitest",
20 | "preview": "vite preview --port 8080",
21 | "predeploy": "yarn build",
22 | "deploy": "gh-pages -d dist",
23 | "audit:fix": "npx yarn-audit-fix"
24 | },
25 | "dependencies": {
26 | "lodash.map": "^4.6.0",
27 | "react": "^18.2.0",
28 | "react-dom": "^18.2.0"
29 | },
30 | "devDependencies": {
31 | "@babel/core": "^7.22.1",
32 | "@babel/plugin-syntax-flow": "^7.21.4",
33 | "@babel/plugin-transform-react-jsx": "^7.22.3",
34 | "@testing-library/dom": "^9.3.0",
35 | "@testing-library/jest-dom": "^5.16.5",
36 | "@testing-library/react": "^14.0.0",
37 | "@testing-library/user-event": "^14.4.3",
38 | "@types/lodash.map": "^4.6.13",
39 | "@types/react": "^18.2.8",
40 | "@types/react-dom": "^18.2.4",
41 | "@typescript-eslint/eslint-plugin": "^5.59.9",
42 | "@typescript-eslint/parser": "^5.59.9",
43 | "@vitejs/plugin-react": "^4.0.0",
44 | "eslint": "^8.42.0",
45 | "eslint-config-prettier": "^8.8.0",
46 | "eslint-config-react-app": "^7.0.1",
47 | "eslint-plugin-import": "^2.27.5",
48 | "eslint-plugin-jsx-a11y": "^6.7.1",
49 | "eslint-plugin-prettier": "^4.2.1",
50 | "eslint-plugin-react": "^7.32.2",
51 | "eslint-plugin-react-hooks": "^4.6.0",
52 | "eslint-plugin-react-refresh": "^0.4.1",
53 | "gh-pages": "^5.0.0",
54 | "jsdom": "^22.1.0",
55 | "postcss": "^8.4.24",
56 | "postcss-scss": "^4.0.6",
57 | "prettier": "^2.8.8",
58 | "rimraf": "^5.0.1",
59 | "sass": "^1.62.1",
60 | "stylelint": "^15.7.0",
61 | "stylelint-prettier": "^3.0.0",
62 | "stylelint-scss": "^5.0.0",
63 | "typescript": "^5.1.3",
64 | "vite": "^4.3.9",
65 | "vitest": "^0.31.4",
66 | "yarn-audit-fix": "^9.3.10"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/App.scss:
--------------------------------------------------------------------------------
1 | .app {
2 | width: 320px;
3 | height: 470px;
4 | position: relative;
5 | }
6 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Calculator } from './components/Calculator';
2 | import './App.scss';
3 |
4 | export function App() {
5 | return (
6 |
7 |
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/components/Calculator/Calculator.scss:
--------------------------------------------------------------------------------
1 | .calculator {
2 | width: 100%;
3 | height: 100%;
4 | display: flex;
5 | flex-direction: column;
6 | box-shadow: 0 0 20px 0 #aaa;
7 | .calculator-keypad {
8 | height: 350px;
9 | display: flex;
10 | }
11 | .input-keys {
12 | width: 240px;
13 | }
14 | .function-keys {
15 | display: flex;
16 | background: linear-gradient(to bottom, rgba(202, 202, 204, 1) 0%, rgba(196, 194, 204, 1) 100%);
17 | .calculator-key {
18 | font-size: 2em;
19 | }
20 | .key-multiply {
21 | line-height: 50px;
22 | }
23 | }
24 | .digit-keys {
25 | background: #e0e0e7;
26 | display: flex;
27 | flex-direction: row;
28 | flex-wrap: wrap;
29 | .calculator-key {
30 | font-size: 2.25em;
31 | }
32 | .key-0 {
33 | width: 160px;
34 | text-align: left;
35 | padding-left: 32px;
36 | }
37 | .key-dot {
38 | padding-top: 1em;
39 | font-size: 0.75em;
40 | }
41 | }
42 | .operator-keys {
43 | background: linear-gradient(to bottom, rgba(252, 156, 23, 1) 0%, rgba(247, 126, 27, 1) 100%);
44 | .calculator-key {
45 | color: white;
46 | border-right: 0;
47 | font-size: 3em;
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/Calculator/Calculator.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import { Calculator } from './Calculator';
4 |
5 | describe('', () => {
6 | const user = userEvent.setup();
7 | it('should sum correctly', async () => {
8 | render();
9 |
10 | await user.click(screen.getByText('6'));
11 | await user.click(screen.getByText('+'));
12 | await user.click(screen.getByText('2'));
13 | await user.click(screen.getByText('='));
14 |
15 | expect(screen.getByTestId('calculator-display-inner')).toHaveTextContent('8');
16 | });
17 |
18 | it('should rest correctly', async () => {
19 | render();
20 | await user.click(screen.getByText('6'));
21 | await user.click(screen.getByText('-'));
22 | await user.click(screen.getByText('2'));
23 | await user.click(screen.getByText('='));
24 |
25 | expect(screen.getByTestId('calculator-display-inner')).toHaveTextContent('4');
26 | });
27 |
28 | it('should divide correctly', async () => {
29 | render();
30 | await user.click(screen.getByText('6'));
31 | await user.click(screen.getByText('÷'));
32 | await user.click(screen.getByText('2'));
33 | await user.click(screen.getByText('='));
34 |
35 | expect(screen.getByTestId('calculator-display-inner')).toHaveTextContent('3');
36 | });
37 |
38 | it('should multiply correctly', async () => {
39 | render();
40 | await user.click(screen.getByText('6'));
41 | await user.click(screen.getByText('x'));
42 | await user.click(screen.getByText('2'));
43 | await user.click(screen.getByText('='));
44 |
45 | expect(screen.getByTestId('calculator-display-inner')).toHaveTextContent('12');
46 | });
47 |
48 | it('should show decimals correctly', async () => {
49 | render();
50 |
51 | await user.click(screen.getByText('●'));
52 | await user.click(screen.getByText('2'));
53 |
54 | expect(screen.getByTestId('calculator-display-inner')).toHaveTextContent('0.2');
55 | });
56 |
57 | it('should invert sign correctly', async () => {
58 | render();
59 |
60 | await user.click(screen.getByText('2'));
61 | await user.click(screen.getByText('±'));
62 |
63 | expect(screen.getByTestId('calculator-display-inner')).toHaveTextContent('-2');
64 | });
65 |
66 | it('should apply % correctly', async () => {
67 | render();
68 | await user.click(screen.getByText('2'));
69 | await user.click(screen.getByText('%'));
70 |
71 | expect(screen.getByTestId('calculator-display-inner')).toHaveTextContent('0.02');
72 | });
73 |
74 | it('should clear the display correctly', async () => {
75 | render();
76 | await user.click(screen.getByText('1'));
77 | await user.click(screen.getByText('2'));
78 | await user.click(screen.getByText('3'));
79 | await user.click(screen.getByText('C'));
80 |
81 | expect(screen.getByTestId('calculator-display-inner')).toHaveTextContent('0');
82 | });
83 | });
84 |
--------------------------------------------------------------------------------
/src/components/Calculator/Calculator.tsx:
--------------------------------------------------------------------------------
1 | import map from 'lodash.map';
2 | import { EInputTypes } from '../../types';
3 | import { useCalculator } from '../../hooks/useCalculator';
4 | import { calculatorOperations, digitKeys } from '../../utils/helpers';
5 | import { CalculatorDisplay } from '../CalculatorDisplay';
6 | import { CalculatorKey } from '../CalculatorKey';
7 | import './Calculator.scss';
8 |
9 | export const Calculator = () => {
10 | const { state, handleClick } = useCalculator();
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 | handleClick(state.displayValue !== '0' ? EInputTypes.clearDisplay : EInputTypes.clearAll)}
21 | keyValue={state.displayValue !== '0' ? 'C' : 'AC'}
22 | />
23 | handleClick(EInputTypes.toggleSign)} keyValue="±" />
24 | handleClick(EInputTypes.inputPercent)} keyValue="%" />
25 |
26 |
27 | {digitKeys.map((digit) => (
28 | handleClick(EInputTypes.inputDigit, digit)}
32 | keyValue={digit}
33 | />
34 | ))}
35 | handleClick(EInputTypes.inputDot)}
38 | keyValue="●"
39 | disabled={state.displayValue.includes('.')}
40 | />
41 |
42 |
43 |
44 | {map(calculatorOperations, (value, key) =>
45 | value.show ? (
46 | handleClick(EInputTypes.performOperation, key)}
50 | keyValue={value.symbol}
51 | />
52 | ) : null,
53 | )}
54 |
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/src/components/Calculator/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Calculator';
2 |
--------------------------------------------------------------------------------
/src/components/CalculatorDisplay/CalculatorDisplay.scss:
--------------------------------------------------------------------------------
1 | .calculator-display {
2 | color: white;
3 | background: #1c191c;
4 | line-height: 130px;
5 | font-size: 6em;
6 | position: relative;
7 | flex: 1;
8 |
9 | &__auto-scaling {
10 | display: inline-block;
11 | padding: 0 30px;
12 | position: absolute;
13 | right: 0;
14 | transform-origin: right;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/CalculatorDisplay/CalculatorDisplay.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import { CalculatorDisplay } from './CalculatorDisplay';
3 |
4 | describe('', () => {
5 | it('should render', () => {
6 | render();
7 | expect(screen.getByText(/111/i)).toBeInTheDocument();
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/CalculatorDisplay/CalculatorDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useRef, useState, useEffect } from 'react';
2 | import { getFormattedValue } from '../../utils/helpers';
3 | import './CalculatorDisplay.scss';
4 |
5 | export interface ICalculatorDisplayProps {
6 | value: string;
7 | }
8 |
9 | export const CalculatorDisplay: FC = ({ value = '0' }) => {
10 | const [scale, setScale] = useState(1);
11 | const parentRef = useRef(null);
12 | const innerRef = useRef(null);
13 |
14 | // eslint-disable-next-line react-hooks/exhaustive-deps
15 | useEffect(() => {
16 | const availableWidth = parentRef?.current?.offsetWidth;
17 | const actualWidth = innerRef?.current?.offsetWidth;
18 | const actualScale = availableWidth && actualWidth ? availableWidth / actualWidth : 1;
19 | if (actualScale < 1) {
20 | setScale(actualScale);
21 | } else if (scale < 1) {
22 | setScale(1);
23 | }
24 | });
25 |
26 | return (
27 |
28 |
34 | {getFormattedValue(value)}
35 |
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/src/components/CalculatorDisplay/index.ts:
--------------------------------------------------------------------------------
1 | export * from './CalculatorDisplay';
2 |
--------------------------------------------------------------------------------
/src/components/CalculatorKey/CalculatorKey.scss:
--------------------------------------------------------------------------------
1 | .calculator-key {
2 | display: block;
3 | width: 80px;
4 | height: 70px;
5 | border: none;
6 | border-top: 1px solid #777;
7 | border-right: 1px solid #666;
8 | background: none;
9 | text-align: center;
10 | line-height: 70px;
11 | padding: 0;
12 | font-family: inherit;
13 | user-select: none;
14 | cursor: pointer;
15 | outline: none;
16 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
17 | &:active {
18 | box-shadow: inset 0 0 80px 0 rgba(0, 0, 0, 0.25);
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/CalculatorKey/CalculatorKey.test.tsx:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest';
2 | import { render, screen, fireEvent } from '@testing-library/react';
3 | import { CalculatorKey } from './CalculatorKey';
4 |
5 | const baseProps = {
6 | onClick: vi.fn(),
7 | keyValue: '1',
8 | };
9 |
10 | describe('', () => {
11 | it('should render', () => {
12 | render();
13 |
14 | expect(screen.getByText(/1/i)).toBeInTheDocument();
15 | });
16 |
17 | it('should call onClick', () => {
18 | render();
19 | fireEvent.click(screen.getByTestId('calculator-key'));
20 |
21 | expect(baseProps.onClick).toHaveBeenCalled();
22 | });
23 |
24 | it('should allow custom className', () => {
25 | const props = {
26 | ...baseProps,
27 | className: 'Custom',
28 | };
29 | render();
30 |
31 | const element = screen.getByTestId('calculator-key');
32 |
33 | expect(element).toHaveClass(props.className);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/components/CalculatorKey/CalculatorKey.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 | import './CalculatorKey.scss';
3 |
4 | interface ICalculatorKeyProps {
5 | keyValue: string | number;
6 | onClick: () => void;
7 | className?: string;
8 | disabled?: boolean;
9 | }
10 |
11 | export const CalculatorKey: FC = ({ onClick, className, keyValue, disabled }) => (
12 |
21 | );
22 |
--------------------------------------------------------------------------------
/src/components/CalculatorKey/index.ts:
--------------------------------------------------------------------------------
1 | export * from './CalculatorKey';
2 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useCalculator';
2 |
--------------------------------------------------------------------------------
/src/hooks/useCalculator.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useReducer } from 'react';
2 | import { calculatorOperations } from '../utils';
3 | import { calculatorReducer, initialState } from '../reducer';
4 | import { EInputTypes } from '../types';
5 |
6 | export const useCalculator = () => {
7 | const [state, dispatch] = useReducer(calculatorReducer, initialState);
8 |
9 | const handleClick = (type: EInputTypes, payload?: string) => {
10 | if (payload) {
11 | dispatch({ type, payload });
12 | } else {
13 | dispatch({ type, payload: null });
14 | }
15 | };
16 |
17 | const handleKeyDown = (e: KeyboardEvent) => {
18 | if (/\d/.test(e.key)) {
19 | e.preventDefault();
20 | dispatch({
21 | type: EInputTypes.inputDigit,
22 | payload: e.key,
23 | });
24 | } else if (e.key in calculatorOperations) {
25 | e.preventDefault();
26 | dispatch({
27 | type: EInputTypes.performOperation,
28 | payload: e.key,
29 | });
30 | } else if (e.key === ',') {
31 | e.preventDefault();
32 | dispatch({
33 | type: EInputTypes.inputDot,
34 | });
35 | } else if (e.key === '.') {
36 | e.preventDefault();
37 | dispatch({
38 | type: EInputTypes.inputDot,
39 | });
40 | } else if (e.key === '%') {
41 | e.preventDefault();
42 | dispatch({
43 | type: EInputTypes.inputPercent,
44 | });
45 | } else if (e.key === 'Backspace') {
46 | e.preventDefault();
47 | dispatch({
48 | type: EInputTypes.clearLastChar,
49 | });
50 | } else if (e.key === 'Clear') {
51 | e.preventDefault();
52 |
53 | if (state.displayValue !== '0') {
54 | dispatch({
55 | type: EInputTypes.clearDisplay,
56 | });
57 | } else {
58 | dispatch({
59 | type: EInputTypes.clearAll,
60 | });
61 | }
62 | }
63 | };
64 |
65 | useEffect(() => {
66 | document.addEventListener('keydown', handleKeyDown);
67 |
68 | return () => {
69 | document.removeEventListener('keydown', handleKeyDown);
70 | };
71 | });
72 |
73 | return { state, handleClick };
74 | };
75 |
--------------------------------------------------------------------------------
/src/index.scss:
--------------------------------------------------------------------------------
1 | html {
2 | box-sizing: border-box;
3 | }
4 |
5 | *,
6 | *:before,
7 | *:after {
8 | box-sizing: inherit;
9 | }
10 |
11 | body {
12 | margin: 0;
13 | font: 100 14px sans-serif;
14 | }
15 |
16 | #root {
17 | height: 100vh;
18 | display: flex;
19 | align-items: center;
20 | justify-content: center;
21 | }
22 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import { App } from './App';
4 | import './index.scss';
5 |
6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/src/reducer/index.ts:
--------------------------------------------------------------------------------
1 | export * from './reducer';
2 |
--------------------------------------------------------------------------------
/src/reducer/reducer.ts:
--------------------------------------------------------------------------------
1 | import { calculatorOperations } from '../utils/helpers';
2 | import { ICalculaterState, EInputTypes, OperactionKeys } from '../types';
3 |
4 | export interface IInputDigit {
5 | type: EInputTypes.inputDigit;
6 | payload: string | null;
7 | }
8 |
9 | export interface IInputDot {
10 | type: EInputTypes.inputDot;
11 | }
12 |
13 | export interface IInputPercent {
14 | type: EInputTypes.inputPercent;
15 | }
16 |
17 | export interface IToggleSign {
18 | type: EInputTypes.toggleSign;
19 | }
20 |
21 | export interface IClearLastChar {
22 | type: EInputTypes.clearLastChar;
23 | }
24 |
25 | export interface IClearDisplay {
26 | type: EInputTypes.clearDisplay;
27 | }
28 |
29 | export interface IPerformOperation {
30 | type: EInputTypes.performOperation;
31 | payload: number | string | null;
32 | }
33 |
34 | export interface IClearAll {
35 | type: EInputTypes.clearAll;
36 | }
37 | export const initialState: ICalculaterState = {
38 | value: null,
39 | displayValue: '0',
40 | operator: null,
41 | waitingForOperand: false,
42 | };
43 |
44 | export const calculatorReducer = (
45 | state: ICalculaterState,
46 | action: IInputDigit | IInputDot | IInputPercent | IToggleSign | IClearLastChar | IClearDisplay | IPerformOperation | IClearAll,
47 | ) => {
48 | switch (action.type) {
49 | case EInputTypes.inputDigit: {
50 | if (state.waitingForOperand) {
51 | return {
52 | ...state,
53 | displayValue: `${action.payload}`,
54 | waitingForOperand: false,
55 | };
56 | }
57 |
58 | return {
59 | ...state,
60 | displayValue: state.displayValue === '0' ? `${action.payload}` : `${state.displayValue}${action.payload}`,
61 | };
62 | }
63 | case EInputTypes.inputDot: {
64 | if (state.waitingForOperand) {
65 | return {
66 | ...state,
67 | displayValue: '0.',
68 | waitingForOperand: false,
69 | };
70 | }
71 |
72 | return {
73 | ...state,
74 | displayValue: `${state.displayValue}.`,
75 | waitingForOperand: false,
76 | };
77 | }
78 |
79 | case EInputTypes.inputPercent: {
80 | if (state.displayValue !== '0') {
81 | const fixedDigits: string = state.displayValue.replace(/^-?\d*\.?/, '');
82 | const newValue: number = parseFloat(state.displayValue) / 100;
83 |
84 | return {
85 | ...state,
86 | displayValue: `${newValue.toFixed(fixedDigits.length + 2)}`,
87 | };
88 | }
89 |
90 | return state;
91 | }
92 |
93 | case EInputTypes.toggleSign: {
94 | const newValue = parseFloat(state.displayValue) * -1;
95 |
96 | return {
97 | ...state,
98 | displayValue: `${newValue}`,
99 | };
100 | }
101 |
102 | case EInputTypes.clearLastChar:
103 | return {
104 | ...state,
105 | displayValue: state.displayValue.substring(0, state.displayValue.length - 1) || '0',
106 | };
107 |
108 | case EInputTypes.clearDisplay:
109 | return {
110 | ...state,
111 | displayValue: '0',
112 | };
113 |
114 | case EInputTypes.performOperation: {
115 | const inputValue = parseFloat(state.displayValue);
116 |
117 | if (state.value === null) {
118 | return {
119 | ...state,
120 | value: inputValue,
121 | operator: action.payload,
122 | waitingForOperand: true,
123 | };
124 | }
125 |
126 | if (state.operator) {
127 | const currentValue = state.value || 0;
128 | const newValue = calculatorOperations[state.operator as OperactionKeys].func(currentValue, inputValue);
129 |
130 | return {
131 | value: newValue,
132 | displayValue: `${newValue}`,
133 | operator: action.payload,
134 | waitingForOperand: true,
135 | };
136 | }
137 |
138 | return {
139 | ...state,
140 | operator: action.payload,
141 | waitingForOperand: false,
142 | };
143 | }
144 |
145 | case EInputTypes.clearAll:
146 | return initialState;
147 |
148 | default:
149 | return initialState;
150 | }
151 | };
152 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import matchers from '@testing-library/jest-dom/matchers';
3 | import { expect } from 'vitest';
4 |
5 | expect.extend(matchers);
6 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export type Digits = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0';
2 | export type OperactionKeys = '/' | '*' | '-' | '+' | '=' | 'Enter';
3 | export type OperationNames = 'divide' | 'multiply' | 'subtract' | 'add' | 'equals' | 'enter';
4 | export type OperationSymbols = '÷' | 'x' | '-' | '+' | '=';
5 | export interface CalculatorValues {
6 | name: OperationNames;
7 | symbol: OperationSymbols;
8 | show: boolean;
9 | func: (prevValue: number, nextValue: number) => number;
10 | }
11 |
12 | export interface ICalculaterState {
13 | value: number | null;
14 | displayValue: string;
15 | operator: string | number | null;
16 | waitingForOperand: boolean;
17 | }
18 |
19 | export type CalculatorOperations = {
20 | [key in OperactionKeys]: CalculatorValues;
21 | };
22 |
23 | export enum EInputTypes {
24 | inputDigit = 'inputDigit',
25 | inputDot = 'inputDot',
26 | inputPercent = 'inputPercent',
27 | toggleSign = 'toggleSign',
28 | clearLastChar = 'clearLastChar',
29 | clearDisplay = 'clearDisplay',
30 | performOperation = 'performOperation',
31 | clearAll = 'clearAll',
32 | }
33 |
--------------------------------------------------------------------------------
/src/utils/helper.test.ts:
--------------------------------------------------------------------------------
1 | import { getFormattedValue, calculatorOperations } from './helpers';
2 |
3 | describe('Format value', () => {
4 | it('should work', () => {
5 | const value = '5.7';
6 | expect(getFormattedValue(value)).toBe(value);
7 | });
8 | });
9 |
10 | describe('Calculator Operations', () => {
11 | it('should divide', () => {
12 | expect(calculatorOperations['/'].func(6, 2)).toBe(3);
13 | });
14 |
15 | it('should multiply', () => {
16 | expect(calculatorOperations['*'].func(6, 2)).toBe(12);
17 | });
18 |
19 | it('should subtract', () => {
20 | expect(calculatorOperations['-'].func(6, 2)).toBe(4);
21 | });
22 |
23 | it('should add', () => {
24 | expect(calculatorOperations['+'].func(6, 2)).toBe(8);
25 | });
26 |
27 | it('should return result', () => {
28 | expect(calculatorOperations['='].func(6, 2)).toBe(2);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | import { Digits, CalculatorOperations } from '../types';
2 |
3 | export const digitKeys: Digits[] = ['7', '8', '9', '4', '5', '6', '1', '2', '3', '0'];
4 |
5 | export const calculatorOperations: CalculatorOperations = {
6 | '/': {
7 | name: 'divide',
8 | symbol: '÷',
9 | show: true,
10 | func: (prevValue: number, nextValue: number) => prevValue / nextValue,
11 | },
12 | '*': {
13 | name: 'multiply',
14 | symbol: 'x',
15 | show: true,
16 | func: (prevValue: number, nextValue: number) => prevValue * nextValue,
17 | },
18 | '-': {
19 | name: 'subtract',
20 | symbol: '-',
21 | show: true,
22 | func: (prevValue: number, nextValue: number) => prevValue - nextValue,
23 | },
24 | '+': {
25 | name: 'add',
26 | symbol: '+',
27 | show: true,
28 | func: (prevValue: number, nextValue: number) => prevValue + nextValue,
29 | },
30 | '=': {
31 | name: 'equals',
32 | symbol: '=',
33 | show: true,
34 | func: (_prevValue: number, nextValue: number) => nextValue,
35 | },
36 | 'Enter': {
37 | name: 'enter',
38 | symbol: '=',
39 | show: false,
40 | func: (_prevValue: number, nextValue: number) => nextValue,
41 | },
42 | };
43 |
44 | export const getFormattedValue = (value: string): string => {
45 | const language = navigator.language || 'en-US';
46 |
47 | let formattedValue = parseFloat(value).toLocaleString(language, {
48 | useGrouping: true,
49 | maximumFractionDigits: 6,
50 | });
51 |
52 | const match = /\.\d*?(0*)$/.exec(value);
53 |
54 | if (match) {
55 | formattedValue += /[1-9]/.test(match[0]) ? match[1] : match[0];
56 | }
57 |
58 | return formattedValue.length >= 14 ? parseFloat(value).toExponential().toString() : formattedValue;
59 | };
60 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './helpers';
2 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 | "baseUrl": "./src",
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["vite.config.ts", ".eslintrc.cjs","src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | ///
3 | ///
4 |
5 | import { defineConfig } from 'vite';
6 | import react from '@vitejs/plugin-react';
7 |
8 | // https://vitejs.dev/config/
9 | export default defineConfig({
10 | plugins: [react()],
11 | base: '/React-Calculator-App/',
12 | test: {
13 | globals: true,
14 | environment: 'jsdom',
15 | setupFiles: ['./src/setupTests.ts'],
16 | },
17 | });
18 |
--------------------------------------------------------------------------------