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