├── .codeclimate.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── default.md ├── auto_assign.yml ├── pull_request_template.md ├── size-badge.svg └── workflows │ ├── ci.yml │ ├── completion.yml │ ├── label_approved.yml │ ├── pr.yml │ ├── publish.yml │ └── release.yml ├── .gitignore ├── .jest └── setup_before_test_case.js ├── .npmignore ├── .storybook ├── main.js └── preview.js ├── CODEOWNERS ├── LICENSE ├── README.md ├── SECURITY.md ├── jest.config.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── rollup.config.js ├── src ├── index.tsx ├── stories │ └── index.stories.tsx ├── tests │ ├── __snapshots__ │ │ └── index.tests.tsx.snap │ ├── index.tests.tsx │ └── utils.tests.ts └── utils.ts └── tsconfig.json /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | checks: 3 | argument-count: 4 | enabled: false 5 | complex-logic: 6 | enabled: false 7 | file-lines: 8 | enabled: true 9 | config: 10 | threshold: 250 11 | method-complexity: 12 | enabled: true 13 | config: 14 | threshold: 5 15 | method-count: 16 | enabled: true 17 | config: 18 | threshold: 20 19 | method-lines: 20 | enabled: true 21 | config: 22 | threshold: 25 23 | exclude_patterns: 24 | - 'dist/' 25 | - 'docs/' 26 | - 'storybook/' 27 | - 'src/tests' 28 | - 'src/stories' 29 | - 'node_modules/' 30 | plugins: 31 | eslint: 32 | enabled: false 33 | nodesecurity: 34 | enabled: false 35 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | max_line_length=120 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | docs/ 3 | dist/ 4 | coverage/ 5 | node_modules/ 6 | *.log 7 | .editorconfig 8 | tsconfig.tsbuildinfo 9 | CODEOWNERS 10 | **/.DS_Store 11 | .npmrc 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:react/recommended", 6 | "plugin:@typescript-eslint/eslint-recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "prettier", 9 | "prettier/react", 10 | ], 11 | plugins: ["@typescript-eslint", "prettier"], 12 | parserOptions: { 13 | ecmaFeatures: { 14 | jsx: true, 15 | modules: true, 16 | }, 17 | }, 18 | rules: { 19 | "@typescript-eslint/explicit-function-return-type": [0], 20 | "@typescript-eslint/no-use-before-define": [0], 21 | "react/prop-types": [0], 22 | "prettier/prettier": "error", 23 | }, 24 | settings: { 25 | react: { 26 | version: "detect", 27 | }, 28 | }, 29 | env: { 30 | browser: true, 31 | node: true, 32 | jest: true, 33 | es6: true, 34 | }, 35 | root: true, 36 | }; 37 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: devlato 4 | custom: http://paypal.me/devlatoau 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '[Bug Report]' 5 | labels: Bug Report 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | 28 | - OS: [e.g. iOS] 29 | - Browser [e.g. chrome, safari] 30 | - Version [e.g. 22] 31 | 32 | **Smartphone (please complete the following information):** 33 | 34 | - Device: [e.g. iPhone6] 35 | - OS: [e.g. iOS8.1] 36 | - Browser [e.g. stock browser, safari] 37 | - Version [e.g. 22] 38 | 39 | **Additional context** 40 | Add any other context about the problem here. 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '[Feature Request]' 5 | labels: Feature Request 6 | assignees: devlato 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/default.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New Pull Request 3 | about: Create a report to help us improve 4 | title: '[PR]' 5 | labels: '' 6 | assignees: 'devlato' 7 | --- 8 | 9 | **Describe the problem this PR solves** 10 | A clear and concise description of what the pull request solves. 11 | 12 | **Features and behaviour** 13 | A list of all the new features this PR adds, along with changed behavior. 14 | 15 | **Describe how it solves the problem** 16 | A bit more detailed explanation of technical implementation. 17 | 18 | **Screenshots** 19 | If applicable, add screenshots to help show off the difference. 20 | 21 | **Additional context** 22 | Add any other context about the PR here, link issues, etc. 23 | -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | addReviewers: true 2 | addAssignees: true 3 | 4 | reviewers: 5 | - devlato 6 | 7 | numberOfReviewers: 0 8 | 9 | assignees: 10 | - devlato 11 | 12 | numberOfAssignees: 0 13 | 14 | skipKeywords: 15 | - WIP 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New Pull Request 3 | about: Create a report to help us improve 4 | title: '[PR]' 5 | labels: '' 6 | assignees: 'devlato' 7 | --- 8 | 9 | **Describe the problem this PR solves** 10 | A clear and concise description of what the pull request solves. 11 | 12 | **Features and behaviour** 13 | A list of all the new features this PR adds, along with changed behavior. 14 | 15 | **Describe how it solves the problem** 16 | A bit more detailed explanation of technical implementation. 17 | 18 | **Screenshots** 19 | If applicable, add screenshots to help show off the difference. 20 | 21 | **Additional context** 22 | Add any other context about the PR here, link issues, etc. 23 | -------------------------------------------------------------------------------- /.github/size-badge.svg: -------------------------------------------------------------------------------- 1 | gzip sizegzip size2.5 kB2.5 kB 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - synchronize 7 | - opened 8 | - edited 9 | - ready_for_review 10 | - reopened 11 | - unlocked 12 | branches: 13 | - master 14 | push: 15 | branches-ignore: 16 | - master 17 | - gh-pages 18 | tags-ignore: 19 | - '*' 20 | create: {} 21 | 22 | jobs: 23 | secrets: 24 | name: Secrets 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v2.0.0 29 | - name: Install dependencies 30 | run: npm ci 31 | - name: Scan for secrets 32 | uses: evanextreme/detect-secrets-action@1.0.0 33 | lint: 34 | name: Lint 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v2.0.0 39 | - name: Install dependencies 40 | run: npm ci 41 | - name: Lint the codebase 42 | run: npm run lint 43 | test: 44 | name: Test 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Checkout 48 | uses: actions/checkout@v2.0.0 49 | - name: Install dependencies 50 | run: npm ci 51 | - name: Run tests 52 | run: npm run test:ci 53 | - name: Upload coverage 54 | uses: actions/upload-artifact@v1 55 | with: 56 | name: test_coverage 57 | path: coverage 58 | build_code: 59 | name: Build code 60 | runs-on: ubuntu-latest 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@v2.0.0 64 | - name: Install dependencies 65 | run: npm ci 66 | - name: Build code 67 | run: npm run build 68 | - name: Upload the dist folder 69 | uses: actions/upload-artifact@v1 70 | with: 71 | name: dist 72 | path: dist 73 | build_storybook: 74 | name: Storybook Build 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Checkout 78 | uses: actions/checkout@v2.0.0 79 | - name: Install dependencies 80 | run: npm ci 81 | - name: Build storybook 82 | run: npm run storybook:build 83 | - name: Upload the storybook 84 | uses: actions/upload-artifact@v1 85 | with: 86 | name: storybook 87 | path: docs 88 | -------------------------------------------------------------------------------- /.github/workflows/completion.yml: -------------------------------------------------------------------------------- 1 | name: Completion 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - labeled 7 | - unlabeled 8 | - synchronize 9 | - opened 10 | - edited 11 | - ready_for_review 12 | - reopened 13 | - unlocked 14 | pull_request_review: 15 | types: 16 | - submitted 17 | check_suite: 18 | types: 19 | - completed 20 | status: {} 21 | 22 | jobs: 23 | maybe_merge: 24 | name: Merge 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v2.0.0 29 | - name: automerge 30 | uses: 'pascalgn/automerge-action@135f0bdb927d9807b5446f7ca9ecc2c51de03c4a' 31 | env: 32 | GITHUB_TOKEN: '${{ secrets.GH_PAT }}' 33 | MERGE_LABELS: Ready to merge 34 | MERGE_METHOD: rebase 35 | MERGE_COMMIT_MESSAGE: 'Merge ${{ github.ref }} into master' 36 | MERGE_RETRIES: 10 37 | MERGE_RETRY_SLEEP: 30000 38 | -------------------------------------------------------------------------------- /.github/workflows/label_approved.yml: -------------------------------------------------------------------------------- 1 | name: Label Approved 2 | 3 | on: pull_request_review 4 | 5 | jobs: 6 | label_when_approved: 7 | name: Label when approved 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Label when approved 11 | uses: pullreminders/label-when-approved-action@1.0.5 12 | env: 13 | APPROVALS: '1' 14 | GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' 15 | ADD_LABEL: 'Ready to merge' 16 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - master 7 | - gh-pages 8 | tags-ignore: 9 | - '*' 10 | pull_request: 11 | branches: 12 | - master 13 | types: 14 | - opened 15 | - reopened 16 | - synchronize 17 | 18 | jobs: 19 | create_pull_request: 20 | name: Create or update a PR 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v1 25 | - name: Create a PR 26 | id: cpr 27 | uses: repo-sync/pull-request@v2.0.1 28 | with: 29 | github_token: '${{ secrets.GITHUB_TOKEN }}' 30 | pr_reviewer: devlato 31 | pr_assignee: devlato 32 | pr_label: '2.0.0' 33 | pr_milestone: '2.0.0' 34 | - name: Generate changelog 35 | run: | 36 | echo "::set-env name=pr_body::$(git log --oneline master..${{ github.ref }})" 37 | echo "PR:\n${pr_body}" 38 | - name: Update pull request 39 | uses: kt3k/update-pr-description@v1.0.0 40 | with: 41 | github_token: '${{ secrets.GITHUB_TOKEN }}' 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | secrets: 10 | name: Secrets 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2.0.0 15 | - name: Install dependencies 16 | run: npm ci 17 | - name: Scan for secrets 18 | uses: evanextreme/detect-secrets-action@1.0.0 19 | lint: 20 | name: Lint 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v2.0.0 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Lint the codebase 28 | run: npm run lint 29 | test: 30 | name: Tests 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v2.0.0 35 | - name: Install dependencies 36 | run: npm ci 37 | - name: Run tests 38 | run: npm run test:ci 39 | - name: Upload coverage 40 | uses: actions/upload-artifact@v1 41 | with: 42 | name: test_coverage 43 | path: coverage 44 | build_storybook: 45 | name: Storybook Build 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Checkout 49 | uses: actions/checkout@v2.0.0 50 | - name: Install dependencies 51 | run: npm ci 52 | - name: Build storybook 53 | run: npm run storybook:build 54 | - name: Upload the storybook 55 | uses: actions/upload-artifact@v1 56 | with: 57 | name: storybook 58 | path: docs 59 | docs: 60 | needs: build_storybook 61 | name: Docs 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: Checkout 65 | uses: actions/checkout@v2.0.0 66 | - name: Install dependencies 67 | run: npm ci 68 | - name: Download storybook 69 | uses: actions/download-artifact@v1 70 | with: 71 | name: storybook 72 | path: docs 73 | - name: Deploy Storybook to GitHub Pages 74 | uses: crazy-max/ghaction-github-pages@v1 75 | with: 76 | target_branch: 'gh-pages' 77 | build_dir: docs 78 | committer_name: 'GitHub Action' 79 | committer_email: 'github@devlato.com' 80 | commit_message: 'Update the Storybook on GitHub Pages' 81 | env: 82 | GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' 83 | GITHUB_PAT: '${{ secrets.GH_PAT }}' 84 | publish: 85 | needs: [secrets, lint, test, build_storybook] 86 | name: Publish to npm 87 | runs-on: ubuntu-latest 88 | steps: 89 | - name: Checkout 90 | uses: actions/checkout@v2.0.0 91 | - name: Install dependencies 92 | run: npm ci 93 | - name: Publish 94 | run: | 95 | echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc 96 | npm publish 97 | env: 98 | GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' 99 | NPM_TOKEN: '${{ secrets.NPM_AUTH_TOKEN }}' 100 | update_changelog: 101 | needs: [publish] 102 | name: Update changelog 103 | runs-on: ubuntu-latest 104 | steps: 105 | - name: Checkout 106 | uses: actions/checkout@v2.0.0 107 | with: 108 | fetch-depth: 0 109 | - name: Generate changelog 110 | uses: ahmadawais/action-auto-changelog@1.0.0 111 | with: 112 | github_token: '${{ secrets.GH_PAT }}' 113 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | secrets: 10 | name: Secrets 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2.0.0 15 | - name: Install dependencies 16 | run: npm ci 17 | - name: Scan for secrets 18 | uses: evanextreme/detect-secrets-action@1.0.0 19 | lint: 20 | name: Lint 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v2.0.0 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Lint the codebase 28 | run: npm run lint 29 | test_and_report_coverage: 30 | name: Coverage 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v2.0.0 35 | - name: Install dependencies 36 | run: npm ci 37 | - name: Run tests 38 | run: npm run test:ci 39 | - name: Run and upload coverage 40 | uses: paambaati/codeclimate-action@v2.5.6 41 | env: 42 | CC_TEST_REPORTER_ID: '${{ secrets.CC_TEST_REPORTER_ID }}' 43 | with: 44 | coverageCommand: npm run test:ci 45 | debug: true 46 | - name: Upload coverage 47 | uses: actions/upload-artifact@v1 48 | with: 49 | name: test_coverage 50 | path: coverage 51 | build_code: 52 | name: Build code 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v2.0.0 57 | - name: Install dependencies 58 | run: npm ci 59 | - name: Build code 60 | run: npm run build 61 | - name: Upload the dist folder 62 | uses: actions/upload-artifact@v1 63 | with: 64 | name: dist 65 | path: dist 66 | build_storybook: 67 | name: Storybook Build 68 | runs-on: ubuntu-latest 69 | steps: 70 | - name: Checkout 71 | uses: actions/checkout@v2.0.0 72 | - name: Install dependencies 73 | run: npm ci 74 | - name: Build storybook 75 | run: npm run storybook:build 76 | - name: Upload the storybook 77 | uses: actions/upload-artifact@v1 78 | with: 79 | name: storybook 80 | path: docs 81 | maybe_tag: 82 | name: Maybe tag the release 83 | runs-on: ubuntu-latest 84 | needs: [secrets, lint, test_and_report_coverage, build_code, build_storybook] 85 | steps: 86 | - name: Checkout 87 | uses: actions/checkout@v2.0.0 88 | - name: Install dependencies 89 | run: npm ci 90 | - name: Maybe generate tag 91 | uses: Klemensas/action-autotag@1.2.3 92 | with: 93 | GITHUB_TOKEN: '${{ secrets.GH_PAT }}' 94 | tag_prefix: '' 95 | tag_suffix: '' 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | dist/ 3 | docs/ 4 | coverage/ 5 | node_modules/ 6 | *.log 7 | tsconfig.tsbuildinfo 8 | **/.DS_Store 9 | .npmrc 10 | -------------------------------------------------------------------------------- /.jest/setup_before_test_case.js: -------------------------------------------------------------------------------- 1 | const Enzyme = require('enzyme'); 2 | const Adapter = require('enzyme-adapter-react-16'); 3 | const { createSerializer } = require('enzyme-to-json'); 4 | 5 | expect.addSnapshotSerializer(createSerializer({ mode: 'deep' })); 6 | Enzyme.configure({ 7 | adapter: new Adapter(), 8 | disableLifecycleMethods: false, 9 | }); 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .jest/ 3 | .github/ 4 | .storybook/ 5 | docs/ 6 | coverage/ 7 | node_modules/ 8 | src/ 9 | *.log 10 | .codeclimate.yml 11 | .eslintrc 12 | .eslintignore 13 | .gitignore 14 | jest.config.js 15 | .travis.yml 16 | .editorconfig 17 | package-lock.json 18 | prettier.config.js 19 | rollup.config.js 20 | tsconfig.json 21 | tsconfig.tsbuildinfo 22 | CODEOWNERS 23 | **/.DS_Store 24 | .npmrc 25 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/stories/*.stories.{ts,tsx}'], 3 | addons: ['@storybook/addon-actions', '@storybook/addon-knobs', '@storybook/preset-typescript'], 4 | }; 5 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { addDecorator, addParameters, configure } from '@storybook/react'; 2 | import { withKnobs } from '@storybook/addon-knobs'; 3 | 4 | addDecorator(withKnobs); 5 | 6 | const isProduction = process.env.NODE_ENV === 'production'; 7 | 8 | addParameters({ 9 | options: { 10 | isFullscreen: isProduction, 11 | enableShortcuts: false, 12 | isToolshown: !isProduction, 13 | name: '', 14 | theme: { 15 | brandName: '', 16 | }, 17 | panelPosition: 'right', 18 | hierarchyRootSeparator: null, 19 | hierarchySeparator: /\./, 20 | storySort: (a, b) => (a[1].kind === b[1].kind ? 0 : a[1].id.localeCompare(b[1].id, undefined, { numeric: true })), 21 | }, 22 | knobs: { 23 | // We're disabling escaping HTML for all cases where we use ASCII characters (e.g. apostrophes) 24 | // on text values in stories. 25 | escapeHTML: false, 26 | }, 27 | }); 28 | 29 | // Automatically import all files ending in *.stories.tsx 30 | const context = require.context('../src', true, /\.stories\.tsx?$/); 31 | configure(context, module); 32 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @devlato 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Denis Tokarev (https://github.com/devlato) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-shortcut 2 | 3 | Convenient React component that detects if the given key combination is pressed, and triggers a callback 4 | 5 | [![View on npm](https://badge.fury.io/js/react-shortcut.svg)](https://npmjs.org/package/react-shortcut) 6 | [![Master Build Status](https://github.com/devlato/react-shortcut/workflows/CI/badge.svg)](https://github.com/devlato/react-shortcut/actions?query=workflow%3ARelease) 7 | [![Release CI Status](https://github.com/devlato/react-shortcut/workflows/Publish/badge.svg)](https://github.com/devlato/react-shortcut/actions?query=workflow%3APublish) 8 | [![Maintainability](https://api.codeclimate.com/v1/badges/f426b7cb20cd324588ad/maintainability)](https://codeclimate.com/github/devlato/react-shortcut/maintainability) 9 | [![Test Coverage](https://api.codeclimate.com/v1/badges/f426b7cb20cd324588ad/test_coverage)](https://codeclimate.com/github/devlato/react-shortcut/test_coverage) 10 | [![Demo](https://img.shields.io/badge/Live%20Demo-Open-yellow)](https://devlato.github.io/react-shortcut/) 11 | [![Bundle size](./.github/size-badge.svg)](https://bundlephobia.com/result?p=react-shortcut) 12 | [![Downloads](https://img.shields.io/npm/dm/react-shortcut)](https://npmjs.org/package/react-shortcut) 13 | [![MIT License](https://img.shields.io/npm/l/react-shortcut)](https://npmjs.org/package/react-shortcut) 14 | [![Issues](https://img.shields.io/github/issues/devlato/react-shortcut)](https://github.com/devlato/react-shortcut/issues) 15 | 16 | ## Installation 17 | 18 | With npm: 19 | 20 | ```sh 21 | $ npm install --save react-shortcut 22 | ``` 23 | 24 | Or with Yarn: 25 | 26 | ```sh 27 | $ yarn add react-shortcut 28 | ``` 29 | 30 | ## Using the component 31 | 32 | Is very simple and straightforward! There are just a couple of props to pass in. 33 | 34 | ### Code example 35 | 36 | ```typescript jsx 37 | import ReactShortcut from 'react-shortcut'; 38 | 39 | // ... 40 | // Somewhere in your component tree 41 | ; 45 | ``` 46 | 47 | ### Props 48 | 49 | All the props are required. 50 | 51 | | Name | Description | Type | 52 | | --------------- | ------------------------------------------------------------------------------------------------------ | ------------------------------- | 53 | | `keys` | A string containing comma-separated key combinations or/and key sequences, or an array of such strings | A string or an array of strings | 54 | | `onKeysPressed` | A callback to be triggered when the user presses any of the specified key combinations | A function with no arguments | 55 | 56 | ### Key combinations and Key sequences 57 | 58 | The component supports both **key combinations** and **key sequences**. 59 | 60 | #### Key combinations 61 | 62 | A **key combination** is a string of key names separated by a plus sign, that describes what keys the user has to press at the same time, to execute the callback specified using `onKeysPressed` prop. 63 | 64 | Examples: `Command+Shift+Left`, `Ctrl+P`. 65 | 66 | To react on the keys combination(s) press, use the following format: 67 | 68 | ```typescript jsx 69 | import ReactShortcut from 'react-shortcut'; 70 | 71 | // Pass in the shortcut keys 72 | 76 | 77 | // ... or an array of shortcuts 78 | 82 | 83 | // ... or a string of comma-separated shortcuts 84 | 88 | ``` 89 | 90 | #### Key sequences 91 | 92 | A **key sequence** is a string of key names separated by a space character, that lists out the keys the user has to press one by one, to trigger the callback specified using `onKeysPressed` prop. 93 | 94 | Examples: `Up Up Down Down Left Right Left Right B A Enter`, `k o n a m i`. 95 | 96 | To react on the keys sequence(s) press, use the following format: 97 | 98 | ```typescript jsx 99 | import ReactShortcut from 'react-shortcut'; 100 | 101 | // Pass in the shortcut keys 102 | 106 | 107 | // ... or an array of shortcuts 108 | 112 | 113 | // ... or a string of comma-separated shortcuts 114 | 118 | ``` 119 | 120 | #### Mixed use 121 | 122 | Mixing both modes is possible –just follow the same key combination/key sequence convention: 123 | 124 | ```typescript jsx 125 | import ReactShortcut from 'react-shortcut'; 126 | 127 | // Array of shortcuts 128 | 132 | 133 | // ... or a string of comma-separated shortcuts 134 | 138 | ``` 139 | 140 | ## FAQ 141 | 142 | ### Does it support TypeScript? 143 | 144 | It does. Moreover, it's implemented in TypeScript. 145 | 146 | ### Do I have to use component only in the root level component? 147 | 148 | Nope. The component adds a global keyboard event listener and doesn't prevent events from bubbling or capturing. 149 | 150 | ### What if my app needs to support multiple shortcuts? 151 | 152 | Just use the component as many times as you need, just make sure the shortcuts aren't repeated. 153 | 154 | ### Do I have to specify the shortcuts in lower case only? 155 | 156 | No, the case doesn't matter. 157 | 158 | ### Any open-source examples of using this library? 159 | 160 | There's an official™️ one called [react-easter](https://www.npmjs.com/package/react-easter), for adding easter eggs triggered by the keypress. 161 | 162 | ## License 163 | 164 | The library is shipped "as is" under MIT License. 165 | 166 | ## Contributing [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/devlato/react-shortcut/issues) 167 | 168 | Feel free to contribute, but don't forget to write tests, mate/matess. 169 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 2.x.x | :white_check_mark: | 11 | | 1.x.x | :x: | 12 | 13 | ## Reporting a Vulnerability 14 | 15 | Use this section to tell people how to report a vulnerability. 16 | 17 | Tell them where to go, how often they can expect to get an update on a 18 | reported vulnerability, what to expect if the vulnerability is accepted or 19 | declined, etc. 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const os = require('os'); 3 | 4 | module.exports = { 5 | preset: 'ts-jest', 6 | automock: false, 7 | browser: true, 8 | clearMocks: true, 9 | resetMocks: true, 10 | resetModules: true, 11 | collectCoverage: true, 12 | displayName: 'ReactHotKey', 13 | maxConcurrency: os.cpus().length, 14 | testURL: 'http://localhost', 15 | moduleDirectories: ['node_modules', 'src'], 16 | setupFilesAfterEnv: ['/.jest/setup_before_test_case.js'], 17 | testPathIgnorePatterns: ['node_modules', '.cache', 'coverage'], 18 | testRegex: '(/tests/.*|(\\.|/)(tests|spec))\\.[jt]sx?$', 19 | }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-shortcut", 3 | "version": "2.0.9", 4 | "description": "Convenient React component that detects if the given key combination is pressed, and triggers a callback", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "homepage": "https://github.com/devlato/react-shortcut", 8 | "bugs": { 9 | "url": "https://github.com/devlato/react-shortcut/issues" 10 | }, 11 | "scripts": { 12 | "format": "prettier --write src .jest", 13 | "lint": "eslint --ext .ts,.tsx,.js,.jsx,.json -c .eslintrc.js .", 14 | "test": "cross-env NODE_ENV=test jest", 15 | "test:ci": "npm run clean:coverage && cross-env NODE_ENV=test jest --coverage", 16 | "clean:build": "rimraf dist", 17 | "clean:coverage": "rimraf coverage", 18 | "clean:storybook": "rimraf docs", 19 | "clean": "npm run clean:build & npm run clean:coverage & npm run clean:storybook && wait", 20 | "build:code": "cross-env NODE_ENV=production rollup -c rollup.config.js", 21 | "build": "npm run clean:build && npm run build:code", 22 | "storybook": "cross-env NODE_ENV=development start-storybook -p 6006 -c .storybook", 23 | "storybook:compile": "cross-env NODE_ENV=production build-storybook -c .storybook -o docs", 24 | "storybook:build": "npm run clean:storybook && npm run storybook:compile", 25 | "pre-push": "npm run lint && npm run test:coverage", 26 | "prepublish": "npm run clean && npm run build" 27 | }, 28 | "lint-staged": { 29 | "src/**/*.{ts,tsx,js,jsx,json}": ["eslint -c ./.eslintrc.js --fix", "jest —-bail --color --findRelatedTests"], 30 | "./*.{ts,tsx,js,jsx,json}": ["eslint -c ./.eslintrc.js --fix", "jest —-bail --color --findRelatedTests"] 31 | }, 32 | "husky": { 33 | "hooks": { 34 | "pre-commit": "lint-staged", 35 | "pre-push": "npm run lint && npm run test && npm run build" 36 | } 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/devlato/react-shortcut.git" 41 | }, 42 | "keywords": ["react", "component", "library", "typescript", "hotkeys", "hotkey", "shortcut", "keyboard"], 43 | "author": "devlato (https://devlato.com/)", 44 | "license": "MIT", 45 | "files": ["package.json", "README.md", "LICENSE", "dist"], 46 | "devDependencies": { 47 | "@babel/core": "^7.9.0", 48 | "@babel/preset-env": "^7.9.0", 49 | "@babel/preset-react": "^7.9.4", 50 | "@rollup/plugin-node-resolve": "^7.1.1", 51 | "@storybook/addon-actions": "^5.3.17", 52 | "@storybook/addon-knobs": "^5.3.17", 53 | "@storybook/addons": "^5.3.17", 54 | "@storybook/cli": "^5.3.17", 55 | "@storybook/preset-typescript": "^3.0.0", 56 | "@storybook/react": "^5.3.17", 57 | "@types/enzyme": "^3.10.4", 58 | "@types/jest": "^24.0.23", 59 | "@types/mousetrap": "^1.6.3", 60 | "@types/node": "^12.12.17", 61 | "@types/prettier": "^1.19.1", 62 | "@types/react": "^16.9.16", 63 | "@types/react-dom": "^16.9.4", 64 | "@typescript-eslint/eslint-plugin": "^2.12.0", 65 | "@typescript-eslint/parser": "^2.12.0", 66 | "babel-loader": "^8.1.0", 67 | "brotli": "^1.3.2", 68 | "coveralls": "^3.0.11", 69 | "cross-env": "^7.0.2", 70 | "enzyme": "^3.11.0", 71 | "enzyme-adapter-react-16": "^1.15.2", 72 | "enzyme-to-json": "^3.4.4", 73 | "eslint": "^6.8.0", 74 | "eslint-config-prettier": "^6.10.1", 75 | "eslint-plugin-prettier": "^3.1.2", 76 | "eslint-plugin-react": "^7.19.0", 77 | "husky": "^4.2.3", 78 | "jest": "^25.2.3", 79 | "lint-staged": "^10.0.9", 80 | "prettier": "^2.0.2", 81 | "react": "*", 82 | "react-dom": "*", 83 | "rimraf": "^3.0.2", 84 | "rollup": "^2.2.0", 85 | "rollup-plugin-commonjs": "^10.1.0", 86 | "rollup-plugin-gzip": "^2.3.0", 87 | "rollup-plugin-terser": "^5.3.0", 88 | "rollup-plugin-typescript2": "^0.27.0", 89 | "ts-jest": "^25.2.1", 90 | "ts-loader": "^6.2.2", 91 | "ts-node": "^8.8.1", 92 | "typescript": "^3.8.3" 93 | }, 94 | "peerDependencies": { 95 | "react": "*", 96 | "react-dom": "*" 97 | }, 98 | "engines": { 99 | "node": ">= 8", 100 | "npm": ">= 4" 101 | }, 102 | "dependencies": { 103 | "mousetrap": "^1.6.5" 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | quoteProps: 'as-needed', 8 | jsxSingleQuote: false, 9 | bracketSpacing: true, 10 | jsxBracketSameLine: false, 11 | arrowParens: 'always', 12 | parser: 'typescript', 13 | htmlWhitespaceSensitivity: 'strict', 14 | endOfLine: 'lf', 15 | printWidth: 120, 16 | overrides: [ 17 | { 18 | files: '.prettierrc', 19 | options: { parser: 'json' }, 20 | }, 21 | { 22 | files: '.babelrc', 23 | options: { parser: 'json' }, 24 | }, 25 | { 26 | files: '.stylelintrc', 27 | options: { parser: 'json' }, 28 | }, 29 | { 30 | files: '*.json', 31 | options: { parser: 'json' }, 32 | }, 33 | { 34 | files: '*.{js,jsx}', 35 | options: { parser: 'babel' }, 36 | }, 37 | { 38 | files: '*.{ts,tsx}', 39 | options: { parser: 'typescript' }, 40 | }, 41 | { 42 | files: '*.scss', 43 | options: { parser: 'scss' }, 44 | }, 45 | { 46 | files: '*.yml', 47 | options: { parser: 'yaml' }, 48 | }, 49 | { 50 | files: '*.md', 51 | options: { parser: 'markdown' }, 52 | }, 53 | ], 54 | }; 55 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import typescript from 'rollup-plugin-typescript2'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | import gzipPlugin from 'rollup-plugin-gzip'; 6 | import { compress } from 'brotli'; 7 | import pckg from './package.json'; 8 | 9 | const isProduction = process.env.NODE_ENV === 'production'; 10 | 11 | export default { 12 | input: 'src/index.tsx', 13 | output: { 14 | dir: 'dist', 15 | globals: { 16 | react: 'React', 17 | }, 18 | format: 'umd', 19 | name: 'ReactHotKey', 20 | sourcemap: true, 21 | banner: [ 22 | `/**`, 23 | ` * ${pckg.name} ${pckg.version}`, 24 | ` * ${pckg.description}`, 25 | ` * ${pckg.homepage}`, 26 | ` * (c) ${pckg.author}, under the ${pckg.license} license`, 27 | ` */`, 28 | ].join('\n'), 29 | compact: true, 30 | }, 31 | plugins: [ 32 | typescript(), 33 | resolve({ resolveOnly: ['mousetrap'] }), 34 | commonjs({ extensions: ['.js', '.jsx', '.ts', '.tsx'] }), 35 | ...(isProduction 36 | ? [ 37 | terser(), 38 | gzipPlugin({ 39 | customCompression: (content) => compress(Buffer.from(content)), 40 | fileName: '.br', 41 | }), 42 | ] 43 | : []), 44 | ], 45 | }; 46 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'mousetrap'; 2 | import * as React from 'react'; 3 | import { normalizeShortcuts, Shortcuts } from './utils'; 4 | 5 | export type ReactHotKeyProps = { 6 | keys: string | string[]; 7 | onKeysPressed: () => void; 8 | }; 9 | 10 | type ReactHotKeyState = { 11 | keys: Shortcuts; 12 | }; 13 | 14 | export default class ReactHotKey extends React.Component { 15 | /** 16 | * Syncs state with props 17 | * @param props 18 | */ 19 | static getDerivedStateFromProps(props: ReactHotKeyProps) { 20 | return { 21 | keys: normalizeShortcuts(props.keys), 22 | }; 23 | } 24 | 25 | constructor(props: ReactHotKeyProps) { 26 | super(props); 27 | 28 | this.onKeysPressed = this.onKeysPressed.bind(this); 29 | 30 | this.state = { 31 | keys: normalizeShortcuts(props.keys), 32 | }; 33 | } 34 | 35 | /** 36 | * Registers mousetrap bindings 37 | */ 38 | componentDidMount() { 39 | Mousetrap.bind(this.state.keys, this.onKeysPressed); 40 | } 41 | 42 | /** 43 | * De-registers mousetrap bindings 44 | */ 45 | componentWillUnmount() { 46 | Mousetrap.unbind(this.state.keys); 47 | } 48 | 49 | /** 50 | * We don't render anything visible 51 | */ 52 | render() { 53 | // We don't need to render anything, really 54 | return null; 55 | } 56 | 57 | /** 58 | * We don't want to re-register a callback every time so we register 59 | * a class method that calls the callback instead 60 | */ 61 | private onKeysPressed() { 62 | this.props.onKeysPressed(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/stories/index.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/camelcase */ 2 | import { text } from '@storybook/addon-knobs'; 3 | import { action } from '@storybook/addon-actions'; 4 | import * as React from 'react'; 5 | import ReactHotKey, { ReactHotKeyProps } from '../index'; 6 | 7 | export default { 8 | title: 'react-shortcut', 9 | component: ReactHotKey, 10 | }; 11 | 12 | export const ReactHotKeyStory_withKeyCombination: React.ComponentType = () => ( 13 | 14 | ); 15 | ReactHotKeyStory_withKeyCombination.displayName = 'ReactHotKeyStory_withKeyCombination'; 16 | 17 | export const ReactHotKeyStory_withKeySequence: React.ComponentType = () => ; 18 | ReactHotKeyStory_withKeySequence.displayName = 'ReactHotKeyStory_withKeySequence'; 19 | 20 | export const ReactHotKeyStory_withMixedShortcuts: React.ComponentType = () => ( 21 | 22 | ); 23 | ReactHotKeyStory_withMixedShortcuts.displayName = 'ReactHotKeyStory_withMixedShortcuts'; 24 | 25 | export const ReactHotKeyStory_withInputField: React.ComponentType = () => ( 26 | 27 | 40 | 41 | ); 42 | ReactHotKeyStory_withMixedShortcuts.displayName = 'ReactHotKeyStory_withMixedShortcuts'; 43 | 44 | const WrappedComponent: React.ComponentType> = ({ children, keys }) => { 45 | const keysKnobs = text('keys', Array.isArray(keys) ? keys.join(',') : keys); 46 | const onKeysPressedAction = action('onKeysPressed'); 47 | 48 | const onKeysPressed = React.useCallback(() => { 49 | alert('onKeysPressed'); 50 | onKeysPressedAction(); 51 | }, []); 52 | 53 | return ( 54 |
74 |
85 |

86 | {Array.isArray(keys) ? ( 87 | <> 88 | Type or press  89 | {keys.map((k, i) => ( 90 | 91 | 99 | {k.includes(' ') ? k.replace(/\s+?/gim, '') : k} 100 | 101 | {i < keys.length - 1 ? ' or ' : null} 102 | 103 | ))} 104 |  to trigger the callback 105 | 106 | ) : keys.includes(',') ? ( 107 | <> 108 | Type or press  109 | {keys.split(',').map((k, i) => ( 110 | 111 | 119 | {k.includes(' ') ? k.replace(/\s+?/gim, '') : k} 120 | 121 | {i < keys.split(',').length - 1 ? ' or ' : null} 122 | 123 | ))} 124 |  to trigger the callback 125 | 126 | ) : keys.includes('+') ? ( 127 | <> 128 | Press  129 | 137 | {keys.includes(' ') ? keys.replace(/\s+?/gim, '') : keys} 138 | 139 |  to trigger the callback 140 | 141 | ) : ( 142 | <> 143 | Type  144 | 152 | {keys.includes(' ') ? keys.replace(/\s+?/gim, '') : keys} 153 | 154 |  to trigger the callback 155 | 156 | )} 157 |

158 | {children} 159 |
160 | 161 |
162 | ); 163 | }; 164 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/index.tests.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders 1`] = `null`; 4 | -------------------------------------------------------------------------------- /src/tests/index.tests.tsx: -------------------------------------------------------------------------------- 1 | import * as mousetrap from 'mousetrap'; 2 | import * as React from 'react'; 3 | import { render, mount } from 'enzyme'; 4 | import ReactHotKey from '../index'; 5 | 6 | describe('', function () { 7 | let onKeysPressed: () => void; 8 | let contaner: HTMLElement; 9 | 10 | beforeEach(() => { 11 | window.document.body.innerHTML = '
'; 12 | contaner = window.document.body.childNodes[0] as HTMLElement; 13 | onKeysPressed = jest.fn(); 14 | }); 15 | 16 | it('renders', function () { 17 | expect(render()).toMatchSnapshot(); 18 | }); 19 | 20 | it('reacts on a key combination', function () { 21 | expect.assertions(2); 22 | 23 | expect(onKeysPressed).not.toHaveBeenCalled(); 24 | mount(, { attachTo: contaner }); 25 | 26 | mousetrap.trigger('shift+command+s'); 27 | expect(onKeysPressed).toHaveBeenCalledTimes(1); 28 | }); 29 | 30 | it('reacts on a key sequence', function () { 31 | expect.assertions(2); 32 | 33 | expect(onKeysPressed).not.toHaveBeenCalled(); 34 | mount(, { attachTo: contaner }); 35 | 36 | mousetrap.trigger('a b c'); 37 | expect(onKeysPressed).toHaveBeenCalledTimes(1); 38 | }); 39 | 40 | it('reacts on a key combination or a key sequence', function () { 41 | expect.assertions(3); 42 | 43 | expect(onKeysPressed).not.toHaveBeenCalled(); 44 | mount(, { attachTo: contaner }); 45 | 46 | mousetrap.trigger('shift+command+s'); 47 | expect(onKeysPressed).toHaveBeenCalledTimes(1); 48 | 49 | mousetrap.trigger('a b c'); 50 | expect(onKeysPressed).toHaveBeenCalledTimes(2); 51 | }); 52 | 53 | it('reacts on a key combination or a key sequence, specified as an array', function () { 54 | expect.assertions(3); 55 | 56 | expect(onKeysPressed).not.toHaveBeenCalled(); 57 | mount(, { attachTo: contaner }); 58 | 59 | mousetrap.trigger('shift+command+s'); 60 | expect(onKeysPressed).toHaveBeenCalledTimes(1); 61 | 62 | mousetrap.trigger('a b c'); 63 | expect(onKeysPressed).toHaveBeenCalledTimes(2); 64 | }); 65 | 66 | it('unmounts', function () { 67 | expect.assertions(5); 68 | 69 | expect(onKeysPressed).not.toHaveBeenCalled(); 70 | const component = mount(, { 71 | attachTo: contaner, 72 | }); 73 | 74 | mousetrap.trigger('shift+command+s'); 75 | expect(onKeysPressed).toHaveBeenCalledTimes(1); 76 | 77 | mousetrap.trigger('a b c'); 78 | expect(onKeysPressed).toHaveBeenCalledTimes(2); 79 | 80 | component.unmount(); 81 | 82 | mousetrap.trigger('shift+command+s'); 83 | expect(onKeysPressed).toHaveBeenCalledTimes(2); 84 | 85 | mousetrap.trigger('a b c'); 86 | expect(onKeysPressed).toHaveBeenCalledTimes(2); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/tests/utils.tests.ts: -------------------------------------------------------------------------------- 1 | import { normalizeShortcuts } from '../utils'; 2 | 3 | describe('utils', function () { 4 | describe('normalizeShortcuts', () => { 5 | it('normalizes a combination', () => { 6 | expect(normalizeShortcuts('shift+command+s')).toEqual(['shift+command+s']); 7 | }); 8 | 9 | it('normalizes an array of combinations', () => { 10 | expect(normalizeShortcuts(['shift+command+s', 'shift+command+m'])).toEqual([ 11 | 'shift+command+s', 12 | 'shift+command+m', 13 | ]); 14 | }); 15 | 16 | it('normalizes a string of combinations', () => { 17 | expect(normalizeShortcuts(['shift+command+s,shift+command+m'])).toEqual(['shift+command+s', 'shift+command+m']); 18 | }); 19 | 20 | it('normalizes a sequence', () => { 21 | expect(normalizeShortcuts('up down left')).toEqual(['up down left']); 22 | }); 23 | 24 | it('normalizes an array of sequences', () => { 25 | expect(normalizeShortcuts(['up down left', 'up down right'])).toEqual(['up down left', 'up down right']); 26 | }); 27 | 28 | it('normalizes a string of sequences', () => { 29 | expect(normalizeShortcuts(['up down left,up down right'])).toEqual(['up down left', 'up down right']); 30 | }); 31 | 32 | it('normalizes a mixed array', () => { 33 | expect(normalizeShortcuts(['shift+command+s', 'up down right'])).toEqual(['shift+command+s', 'up down right']); 34 | }); 35 | 36 | it('normalizes a mixed string', () => { 37 | expect(normalizeShortcuts(['shift+command+s,up down right'])).toEqual(['shift+command+s', 'up down right']); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This function converts input keys into an array of strings, one string per key combination/key sequence 3 | * 4 | * @param keys String, comma-separated string, or an array of string specifying key combinations/sequences 5 | */ 6 | export const normalizeShortcuts = (keys: string | string[]): Shortcuts => 7 | Array.isArray(keys) 8 | ? keys.map(normalizeShortcuts).reduce((acc, v) => acc.concat(v), []) 9 | : keys 10 | .split(',') 11 | .map((str) => str.trim().toLowerCase() as Shortcut) 12 | .filter((str) => str !== ''); 13 | 14 | export type Shortcuts = Shortcut[]; 15 | 16 | type KeyCombination = string & { keyCombination: never }; 17 | type KeySequence = string & { keySequence: never }; 18 | type Shortcut = KeyCombination | KeySequence; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": false, 4 | "module": "esnext", 5 | "target": "es6", 6 | "sourceMap": true, 7 | "incremental": true, 8 | "newLine": "LF", 9 | "noEmitHelpers": false, 10 | "noImplicitAny": true, 11 | "noImplicitThis": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "outDir": "dist", 15 | "declaration": true, 16 | "declarationDir": "dist", 17 | "preserveConstEnums": false, 18 | "pretty": true, 19 | "removeComments": true, 20 | "experimentalDecorators": true, 21 | "emitDecoratorMetadata": true, 22 | "allowUnusedLabels": false, 23 | "noImplicitReturns": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "allowUnreachableCode": false, 26 | "baseUrl": "./src", 27 | "allowJs": false, 28 | "allowSyntheticDefaultImports": false, 29 | "esModuleInterop": true, 30 | "noImplicitUseStrict": false, 31 | "lib": ["es5", "es6", "es7", "esnext", "dom"], 32 | "strictNullChecks": true, 33 | "importHelpers": false, 34 | "alwaysStrict": true, 35 | "strict": true, 36 | "strictBindCallApply": true, 37 | "strictFunctionTypes": true, 38 | "strictPropertyInitialization": true, 39 | "jsx": "react", 40 | "noErrorTruncation": true, 41 | "disableSizeLimit": true, 42 | "resolveJsonModule": true, 43 | "moduleResolution": "node", 44 | "tsBuildInfoFile": "dist/tsbuildinfo" 45 | }, 46 | "include": ["src/**/*"], 47 | "exclude": ["node_modules", "src/tests", "src/stories"] 48 | } 49 | --------------------------------------------------------------------------------