├── .gitattributes ├── .npmrc ├── .prettierignore ├── .huskyrc.js ├── .prettierrc.js ├── tsconfig.json ├── other ├── EXAMPLES.md ├── USERS.md ├── manual-releases.md └── MAINTAINING.md ├── .gitignore ├── CHANGELOG.md ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── validate.yml ├── LICENSE ├── package.json ├── CONTRIBUTING.md ├── CODE_OF_CONDUCT.md ├── .all-contributorsrc ├── src ├── __tests__ │ └── index.ts └── index.ts └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /.huskyrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('kcd-scripts/husky') 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('kcd-scripts/prettier') 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/kcd-scripts/shared-tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /other/EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | - [Basic example](https://codesandbox.io/s/inspiring-voice-4msg5) 4 | - [With Downshift](https://codesandbox.io/s/react-codesandbox-jnuq8) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .DS_Store 5 | 6 | # these cause more harm than good 7 | # when working with contributors 8 | package-lock.json 9 | yarn.lock 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | The changelog is automatically updated using 4 | [semantic-release](https://github.com/semantic-release/semantic-release). You 5 | can see it on the [releases page](../../releases). 6 | -------------------------------------------------------------------------------- /other/USERS.md: -------------------------------------------------------------------------------- 1 | # Users 2 | 3 | If you or your company uses this project, add your name to this list! Eventually 4 | we may have a website to showcase these (wanna build it!?) 5 | 6 | > No users have been added yet! 7 | 8 | 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | - `match-sorter` version: 15 | - `node` version: 16 | 17 | Relevant code or config 18 | 19 | ```javascript 20 | 21 | ``` 22 | 23 | What you did: 24 | 25 | What happened: 26 | 27 | 28 | 29 | Reproduction repository: 30 | 31 | 35 | 36 | Problem description: 37 | 38 | Suggested solution: 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2020 Kent C. Dodds 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | **What**: 20 | 21 | 22 | 23 | **Why**: 24 | 25 | 26 | 27 | **How**: 28 | 29 | 30 | 31 | **Checklist**: 32 | 33 | 34 | 35 | 36 | - [ ] Documentation 37 | - [ ] Tests 38 | - [ ] Ready to be merged 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /other/manual-releases.md: -------------------------------------------------------------------------------- 1 | # manual-releases 2 | 3 | This project has an automated release set up. So things are only released when 4 | there are useful changes in the code that justify a release. But sometimes 5 | things get messed up one way or another and we need to trigger the release 6 | ourselves. When this happens, simply bump the number below and commit that with 7 | the following commit message based on your needs: 8 | 9 | **Major** 10 | 11 | ``` 12 | fix(release): manually release a major version 13 | 14 | There was an issue with a major release, so this manual-releases.md 15 | change is to release a new major version. 16 | 17 | Reference: # 18 | 19 | BREAKING CHANGE: 20 | ``` 21 | 22 | **Minor** 23 | 24 | ``` 25 | feat(release): manually release a minor version 26 | 27 | There was an issue with a minor release, so this manual-releases.md 28 | change is to release a new minor version. 29 | 30 | Reference: # 31 | ``` 32 | 33 | **Patch** 34 | 35 | ``` 36 | fix(release): manually release a patch version 37 | 38 | There was an issue with a patch release, so this manual-releases.md 39 | change is to release a new patch version. 40 | 41 | Reference: # 42 | ``` 43 | 44 | The number of times we've had to do a manual release is: 0 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "match-sorter", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Simple, expected, and deterministic best-match sorting of an array in JavaScript", 5 | "main": "dist/match-sorter.cjs.js", 6 | "module": "dist/match-sorter.esm.js", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "build": "kcd-scripts build --bundle --environment BUILD_NAME:matchSorter", 10 | "lint": "kcd-scripts lint", 11 | "setup": "npm install && npm run validate -s", 12 | "test": "kcd-scripts test", 13 | "typecheck": "kcd-scripts typecheck", 14 | "test:update": "npm test -- --updateSnapshot --coverage", 15 | "validate": "kcd-scripts validate" 16 | }, 17 | "files": [ 18 | "dist" 19 | ], 20 | "keywords": [ 21 | "autocomplete", 22 | "filter list", 23 | "sort", 24 | "advanced sort", 25 | "user intuitive sort" 26 | ], 27 | "author": "Kent C. Dodds (https://kentcdodds.com)", 28 | "license": "MIT", 29 | "dependencies": { 30 | "@babel/runtime": "^7.12.5", 31 | "remove-accents": "0.4.2" 32 | }, 33 | "devDependencies": { 34 | "@types/jest": "^26.0.15", 35 | "kcd-scripts": "^7.2.0", 36 | "typescript": "^4.1.2" 37 | }, 38 | "eslintConfig": { 39 | "extends": [ 40 | "./node_modules/kcd-scripts/eslint.js" 41 | ] 42 | }, 43 | "eslintIgnore": [ 44 | "node_modules", 45 | "coverage", 46 | "dist" 47 | ], 48 | "repository": { 49 | "type": "git", 50 | "url": "https://github.com/kentcdodds/match-sorter" 51 | }, 52 | "bugs": { 53 | "url": "https://github.com/kentcdodds/match-sorter/issues" 54 | }, 55 | "homepage": "https://github.com/kentcdodds/match-sorter#readme" 56 | } 57 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | **Working on your first Pull Request?** You can learn how from this _free_ 6 | series [How to Contribute to an Open Source Project on GitHub][egghead] 7 | 8 | ## Project setup 9 | 10 | 1. Fork and clone the repo 11 | 2. Run `npm run setup -s` to install dependencies and run validation 12 | 3. Create a branch for your PR with `git checkout -b pr/your-branch-name` 13 | 14 | > Tip: Keep your `master` branch pointing at the original repository and make 15 | > pull requests from branches on your fork. To do this, run: 16 | > 17 | > ``` 18 | > git remote add upstream https://github.com/kentcdodds/match-sorter 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/master master 21 | > ``` 22 | > 23 | > This will add the original repository as a "remote" called "upstream," Then 24 | > fetch the git information from that remote, then set your local `master` 25 | > branch to use the upstream master branch whenever you run `git pull`. Then you 26 | > can make all of your pull request branches based on this `master` branch. 27 | > Whenever you want to update your version of `master`, do a regular `git pull`. 28 | 29 | ## Committing and Pushing changes 30 | 31 | Please make sure to run the tests before you commit your changes. You can run 32 | `npm run test:update` which will update any snapshots that need updating. Make 33 | sure to include those changes (if they exist) in your commit. 34 | 35 | ## Help needed 36 | 37 | Please checkout the [the open issues][issues] 38 | 39 | Also, please watch the repo and respond to questions/bug reports/feature 40 | requests! Thanks! 41 | 42 | 43 | [egghead]: https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github 44 | [all-contributors]: https://github.com/all-contributors/all-contributors 45 | [issues]: https://github.com/kentcdodds/match-sorter/issues 46 | 47 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: validate 2 | on: 3 | push: 4 | branches: 5 | - '+([0-9])?(.{+([0-9]),x}).x' 6 | - 'master' 7 | - 'next' 8 | - 'next-major' 9 | - 'beta' 10 | - 'alpha' 11 | - '!all-contributors/**' 12 | pull_request: {} 13 | jobs: 14 | main: 15 | # ignore all-contributors PRs 16 | if: ${{ !contains(github.head_ref, 'all-contributors') }} 17 | strategy: 18 | matrix: 19 | node: [10.13, 12, 14, 15] 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: 🛑 Cancel Previous Runs 23 | uses: styfle/cancel-workflow-action@0.6.0 24 | with: 25 | access_token: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: ⬇️ Checkout repo 28 | uses: actions/checkout@v2 29 | 30 | - name: ⎔ Setup node 31 | uses: actions/setup-node@v1 32 | with: 33 | node-version: ${{ matrix.node }} 34 | 35 | - name: 📥 Download deps 36 | uses: bahmutov/npm-install@v1 37 | with: 38 | useLockFile: false 39 | env: 40 | HUSKY_SKIP_INSTALL: true 41 | 42 | - name: ▶️ Run validate script 43 | run: npm run validate 44 | 45 | - name: ⬆️ Upload coverage report 46 | uses: codecov/codecov-action@v1 47 | 48 | release: 49 | needs: main 50 | runs-on: ubuntu-latest 51 | if: 52 | ${{ github.repository == 'kentcdodds/match-sorter' && 53 | contains('refs/heads/master,refs/heads/beta,refs/heads/next,refs/heads/alpha', 54 | github.ref) && github.event_name == 'push' }} 55 | steps: 56 | - name: 🛑 Cancel Previous Runs 57 | uses: styfle/cancel-workflow-action@0.6.0 58 | with: 59 | access_token: ${{ secrets.GITHUB_TOKEN }} 60 | 61 | - name: ⬇️ Checkout repo 62 | uses: actions/checkout@v2 63 | 64 | - name: ⎔ Setup node 65 | uses: actions/setup-node@v1 66 | with: 67 | node-version: 14 68 | 69 | - name: 📥 Download deps 70 | uses: bahmutov/npm-install@v1 71 | with: 72 | useLockFile: false 73 | env: 74 | HUSKY_SKIP_INSTALL: true 75 | 76 | - name: 🏗 Run build script 77 | run: npm run build 78 | 79 | - name: 🚀 Release 80 | uses: cycjimmy/semantic-release-action@v2 81 | with: 82 | semantic_version: 17 83 | branches: | 84 | [ 85 | '+([0-9])?(.{+([0-9]),x}).x', 86 | 'master', 87 | 'next', 88 | 'next-major', 89 | {name: 'beta', prerelease: true}, 90 | {name: 'alpha', prerelease: true} 91 | ] 92 | env: 93 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 94 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 95 | -------------------------------------------------------------------------------- /other/MAINTAINING.md: -------------------------------------------------------------------------------- 1 | # Maintaining 2 | 3 | This is documentation for maintainers of this project. 4 | 5 | ## Code of Conduct 6 | 7 | Please review, understand, and be an example of it. Violations of the code of 8 | conduct are taken seriously, even (especially) for maintainers. 9 | 10 | ## Issues 11 | 12 | We want to support and build the community. We do that best by helping people 13 | learn to solve their own problems. We have an issue template and hopefully most 14 | folks follow it. If it's not clear what the issue is, invite them to create a 15 | minimal reproduction of what they're trying to accomplish or the bug they think 16 | they've found. 17 | 18 | Once it's determined that a code change is necessary, point people to 19 | [makeapullrequest.com](http://makeapullrequest.com) and invite them to make a 20 | pull request. If they're the one who needs the feature, they're the one who can 21 | build it. If they need some hand holding and you have time to lend a hand, 22 | please do so. It's an investment into another human being, and an investment 23 | into a potential maintainer. 24 | 25 | Remember that this is open source, so the code is not yours, it's ours. If 26 | someone needs a change in the codebase, you don't have to make it happen 27 | yourself. Commit as much time to the project as you want/need to. Nobody can ask 28 | any more of you than that. 29 | 30 | ## Pull Requests 31 | 32 | As a maintainer, you're fine to make your branches on the main repo or on your 33 | own fork. Either way is fine. 34 | 35 | When we receive a pull request, a github action is kicked off automatically (see 36 | the `.github/workflows/validate.yml` for what runs in the action). We avoid 37 | merging anything that breaks the validate action. 38 | 39 | Please review PRs and focus on the code rather than the individual. You never 40 | know when this is someone's first ever PR and we want their experience to be as 41 | positive as possible, so be uplifting and constructive. 42 | 43 | When you merge the pull request, 99% of the time you should use the 44 | [Squash and merge](https://help.github.com/articles/merging-a-pull-request/) 45 | feature. This keeps our git history clean, but more importantly, this allows us 46 | to make any necessary changes to the commit message so we release what we want 47 | to release. See the next section on Releases for more about that. 48 | 49 | ## Release 50 | 51 | Our releases are automatic. They happen whenever code lands into `master`. A 52 | github action gets kicked off and if it's successful, a tool called 53 | [`semantic-release`](https://github.com/semantic-release/semantic-release) is 54 | used to automatically publish a new release to npm as well as a changelog to 55 | GitHub. It is only able to determine the version and whether a release is 56 | necessary by the git commit messages. With this in mind, **please brush up on 57 | [the commit message convention][commit] which drives our releases.** 58 | 59 | > One important note about this: Please make sure that commit messages do NOT 60 | > contain the words "BREAKING CHANGE" in them unless we want to push a major 61 | > version. I've been burned by this more than once where someone will include 62 | > "BREAKING CHANGE: None" and it will end up releasing a new major version. Not 63 | > a huge deal honestly, but kind of annoying... 64 | 65 | ## Thanks! 66 | 67 | Thank you so much for helping to maintain this project! 68 | 69 | [commit]: 70 | https://github.com/conventional-changelog-archived-repos/conventional-changelog-angular/blob/ed32559941719a130bb0327f886d6a32a8cbc2ba/convention.md 71 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | me+coc@kentcdodds.com. All complaints will be reviewed and investigated promptly 64 | and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "match-sorter", 3 | "projectOwner": "kentcdodds", 4 | "imageSize": 100, 5 | "commit": false, 6 | "contributorsPerLine": 7, 7 | "repoHost": "https://github.com", 8 | "repoType": "github", 9 | "skipCi": false, 10 | "files": [ 11 | "README.md" 12 | ], 13 | "contributors": [ 14 | { 15 | "login": "kentcdodds", 16 | "name": "Kent C. Dodds", 17 | "avatar_url": "https://avatars.githubusercontent.com/u/1500684?v=3", 18 | "profile": "https://kentcdodds.com", 19 | "contributions": [ 20 | "code", 21 | "doc", 22 | "infra", 23 | "test", 24 | "review" 25 | ] 26 | }, 27 | { 28 | "login": "conorhastings", 29 | "name": "Conor Hastings", 30 | "avatar_url": "https://avatars.githubusercontent.com/u/8263298?v=3", 31 | "profile": "http://conorhastings.com", 32 | "contributions": [ 33 | "code", 34 | "doc", 35 | "test", 36 | "review" 37 | ] 38 | }, 39 | { 40 | "login": "rogeliog", 41 | "name": "Rogelio Guzman", 42 | "avatar_url": "https://avatars.githubusercontent.com/u/574806?v=3", 43 | "profile": "https://github.com/rogeliog", 44 | "contributions": [ 45 | "doc" 46 | ] 47 | }, 48 | { 49 | "login": "clauderic", 50 | "name": "Claudéric Demers", 51 | "avatar_url": "https://avatars.githubusercontent.com/u/1416436?v=3", 52 | "profile": "http://ced.io", 53 | "contributions": [ 54 | "code", 55 | "doc", 56 | "test" 57 | ] 58 | }, 59 | { 60 | "login": "osfan501", 61 | "name": "Kevin Davis", 62 | "avatar_url": "https://avatars3.githubusercontent.com/u/4150097?v=3", 63 | "profile": "kevindav.us", 64 | "contributions": [ 65 | "code", 66 | "test" 67 | ] 68 | }, 69 | { 70 | "login": "nfdjps", 71 | "name": "Denver Chen", 72 | "avatar_url": "https://avatars1.githubusercontent.com/u/19157735?v=3", 73 | "profile": "https://github.com/nfdjps", 74 | "contributions": [ 75 | "code", 76 | "doc", 77 | "test" 78 | ] 79 | }, 80 | { 81 | "login": "ChrisRu", 82 | "name": "Christian Ruigrok", 83 | "avatar_url": "https://avatars0.githubusercontent.com/u/12719057?v=4", 84 | "profile": "http://ruigrok.info", 85 | "contributions": [ 86 | "bug", 87 | "code", 88 | "doc" 89 | ] 90 | }, 91 | { 92 | "login": "hozefaj", 93 | "name": "Hozefa", 94 | "avatar_url": "https://avatars1.githubusercontent.com/u/2084833?v=4", 95 | "profile": "https://github.com/hozefaj", 96 | "contributions": [ 97 | "bug", 98 | "code", 99 | "test", 100 | "ideas" 101 | ] 102 | }, 103 | { 104 | "login": "pushpinder107", 105 | "name": "pushpinder107", 106 | "avatar_url": "https://avatars3.githubusercontent.com/u/9403361?v=4", 107 | "profile": "https://github.com/pushpinder107", 108 | "contributions": [ 109 | "code" 110 | ] 111 | }, 112 | { 113 | "login": "tikotzky", 114 | "name": "Mordy Tikotzky", 115 | "avatar_url": "https://avatars3.githubusercontent.com/u/200528?v=4", 116 | "profile": "https://github.com/tikotzky", 117 | "contributions": [ 118 | "code", 119 | "doc", 120 | "test" 121 | ] 122 | }, 123 | { 124 | "login": "sdbrannum", 125 | "name": "Steven Brannum", 126 | "avatar_url": "https://avatars1.githubusercontent.com/u/11765845?v=4", 127 | "profile": "https://github.com/sdbrannum", 128 | "contributions": [ 129 | "code", 130 | "test" 131 | ] 132 | }, 133 | { 134 | "login": "cmeeren", 135 | "name": "Christer van der Meeren", 136 | "avatar_url": "https://avatars0.githubusercontent.com/u/7766733?v=4", 137 | "profile": "https://github.com/cmeeren", 138 | "contributions": [ 139 | "bug" 140 | ] 141 | }, 142 | { 143 | "login": "samyan", 144 | "name": "Samuel Petrosyan", 145 | "avatar_url": "https://avatars0.githubusercontent.com/u/3801362?v=4", 146 | "profile": "http://securitynull.net/", 147 | "contributions": [ 148 | "code", 149 | "bug" 150 | ] 151 | }, 152 | { 153 | "login": "brandonkal", 154 | "name": "Brandon Kalinowski", 155 | "avatar_url": "https://avatars3.githubusercontent.com/u/4714862?v=4", 156 | "profile": "https://brandonkalinowski.com", 157 | "contributions": [ 158 | "bug" 159 | ] 160 | }, 161 | { 162 | "login": "coderberry", 163 | "name": "Eric Berry", 164 | "avatar_url": "https://avatars2.githubusercontent.com/u/12481?v=4", 165 | "profile": "https://codefund.io", 166 | "contributions": [ 167 | "fundingFinding" 168 | ] 169 | }, 170 | { 171 | "login": "skube", 172 | "name": "Skubie Doo", 173 | "avatar_url": "https://avatars3.githubusercontent.com/u/146396?v=4", 174 | "profile": "https://github.com/skube", 175 | "contributions": [ 176 | "doc" 177 | ] 178 | }, 179 | { 180 | "login": "MichaelDeBoey", 181 | "name": "Michaël De Boey", 182 | "avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4", 183 | "profile": "https://michaeldeboey.be", 184 | "contributions": [ 185 | "code", 186 | "review" 187 | ] 188 | }, 189 | { 190 | "login": "tannerlinsley", 191 | "name": "Tanner Linsley", 192 | "avatar_url": "https://avatars0.githubusercontent.com/u/5580297?v=4", 193 | "profile": "https://tannerlinsley.com", 194 | "contributions": [ 195 | "code" 196 | ] 197 | }, 198 | { 199 | "login": "SweVictor", 200 | "name": "Victor", 201 | "avatar_url": "https://avatars1.githubusercontent.com/u/449347?v=4", 202 | "profile": "https://github.com/SweVictor", 203 | "contributions": [ 204 | "doc" 205 | ] 206 | }, 207 | { 208 | "login": "RebeccaStevens", 209 | "name": "Rebecca Stevens", 210 | "avatar_url": "https://avatars1.githubusercontent.com/u/7224206?v=4", 211 | "profile": "https://github.com/RebeccaStevens", 212 | "contributions": [ 213 | "bug", 214 | "doc" 215 | ] 216 | }, 217 | { 218 | "login": "marcosvega91", 219 | "name": "Marco Moretti", 220 | "avatar_url": "https://avatars2.githubusercontent.com/u/5365582?v=4", 221 | "profile": "https://github.com/marcosvega91", 222 | "contributions": [ 223 | "doc" 224 | ] 225 | }, 226 | { 227 | "login": "rbusquet", 228 | "name": "Ricardo Busquet", 229 | "avatar_url": "https://avatars1.githubusercontent.com/u/7198302?v=4", 230 | "profile": "https://ricardobusquet.com", 231 | "contributions": [ 232 | "ideas", 233 | "review", 234 | "code" 235 | ] 236 | }, 237 | { 238 | "login": "weyert", 239 | "name": "Weyert de Boer", 240 | "avatar_url": "https://avatars3.githubusercontent.com/u/7049?v=4", 241 | "profile": "https://github.com/weyert", 242 | "contributions": [ 243 | "ideas", 244 | "review" 245 | ] 246 | }, 247 | { 248 | "login": "PhilGarb", 249 | "name": "Philipp Garbowsky", 250 | "avatar_url": "https://avatars3.githubusercontent.com/u/38015558?v=4", 251 | "profile": "https://github.com/PhilGarb", 252 | "contributions": [ 253 | "code" 254 | ] 255 | } 256 | ] 257 | } 258 | -------------------------------------------------------------------------------- /src/__tests__/index.ts: -------------------------------------------------------------------------------- 1 | import {matchSorter, rankings, MatchSorterOptions} from '../' 2 | 3 | type TestCase = { 4 | input: [Array, string, MatchSorterOptions?] 5 | output: Array 6 | only?: boolean 7 | skip?: boolean 8 | } 9 | 10 | const tests: Record = { 11 | 'returns an empty array with a string that matches no items': { 12 | input: [['Chakotay', 'Charzard'], 'nomatch'], 13 | output: [], 14 | }, 15 | 'returns the items that match': { 16 | input: [['Chakotay', 'Brunt', 'Charzard'], 'Ch'], 17 | output: ['Chakotay', 'Charzard'], 18 | }, 19 | 'returns items that match in the best order': { 20 | input: [ 21 | [ 22 | 'The Tail of Two Cities 1', // acronym 23 | 'tTOtc', // equal 24 | 'ttotc', // case-sensitive-equal 25 | 'The 1-ttotc-2 container', // contains 26 | 'The Tail of Forty Cities', // match 27 | 'The Tail of Two Cities', // acronym2 28 | 'kebab-ttotc-case', // case string 29 | 'Word starts with ttotc-first right?', // wordStartsWith 30 | 'The Tail of Fifty Cities', // match2 31 | 'no match', // no match 32 | 'The second 3-ttotc-4 container', // contains2 33 | 'ttotc-starts with', // startsWith 34 | 'Another word starts with ttotc-second, super!', // wordStartsWith2 35 | 'ttotc-2nd-starts with', // startsWith2 36 | 'TTotc', // equal2, 37 | ], 38 | 'ttotc', 39 | ], 40 | output: [ 41 | 'ttotc', // case-sensitive-equal 42 | 'tTOtc', // equal 43 | 'TTotc', // equal2 44 | 'ttotc-2nd-starts with', // startsWith 45 | 'ttotc-starts with', // startsWith2 46 | 'Another word starts with ttotc-second, super!', // wordStartsWith 47 | 'Word starts with ttotc-first right?', // wordStartsWith2 48 | 'kebab-ttotc-case', // case string 49 | 'The 1-ttotc-2 container', // contains 50 | 'The second 3-ttotc-4 container', // contains2 51 | 'The Tail of Two Cities', // acronym 52 | 'The Tail of Two Cities 1', // acronym2 53 | 'The Tail of Fifty Cities', // match 54 | 'The Tail of Forty Cities', // match2 55 | ], 56 | }, 57 | 'no match for single character inputs that are not equal': { 58 | input: [['abc'], 'd'], 59 | output: [], 60 | }, 61 | 'can handle objects when specifying a key': { 62 | input: [ 63 | [{name: 'baz'}, {name: 'bat'}, {name: 'foo'}], 64 | 'ba', 65 | {keys: ['name']}, 66 | ], 67 | output: [{name: 'bat'}, {name: 'baz'}], 68 | }, 69 | 'can handle multiple keys specified': { 70 | input: [ 71 | [ 72 | {name: 'baz', reverse: 'zab'}, 73 | {name: 'bat', reverse: 'tab'}, 74 | {name: 'foo', reverse: 'oof'}, 75 | {name: 'bag', reverse: 'gab'}, 76 | ], 77 | 'ab', 78 | {keys: ['name', 'reverse']}, 79 | ], 80 | output: [ 81 | {name: 'bag', reverse: 'gab'}, 82 | {name: 'bat', reverse: 'tab'}, 83 | {name: 'baz', reverse: 'zab'}, 84 | ], 85 | }, 86 | 'with multiple keys specified, all other things being equal, it prioritizes key index over alphabetizing': { 87 | input: [ 88 | [ 89 | {first: 'not', second: 'not', third: 'match'}, 90 | {first: 'not', second: 'not', third: 'not', fourth: 'match'}, 91 | {first: 'not', second: 'match'}, 92 | {first: 'match', second: 'not'}, 93 | ], 94 | 'match', 95 | {keys: ['first', 'second', 'third', 'fourth']}, 96 | ], 97 | output: [ 98 | {first: 'match', second: 'not'}, 99 | {first: 'not', second: 'match'}, 100 | {first: 'not', second: 'not', third: 'match'}, 101 | {first: 'not', second: 'not', third: 'not', fourth: 'match'}, 102 | ], 103 | }, 104 | 'can handle the number 0 as a property value': { 105 | input: [ 106 | [ 107 | {name: 'A', age: 0}, 108 | {name: 'B', age: 1}, 109 | {name: 'C', age: 2}, 110 | {name: 'D', age: 3}, 111 | ], 112 | '0', 113 | {keys: ['age']}, 114 | ], 115 | output: [{name: 'A', age: 0}], 116 | }, 117 | 'can handle objected with nested keys': { 118 | input: [ 119 | [ 120 | {name: {first: 'baz'}}, 121 | {name: {first: 'bat'}}, 122 | {name: {first: 'foo'}}, 123 | {name: null}, 124 | {}, 125 | null, 126 | ], 127 | 'ba', 128 | {keys: ['name.first']}, 129 | ], 130 | output: [{name: {first: 'bat'}}, {name: {first: 'baz'}}], 131 | }, 132 | 'can handle property callback': { 133 | input: [ 134 | [{name: {first: 'baz'}}, {name: {first: 'bat'}}, {name: {first: 'foo'}}], 135 | 'ba', 136 | // @ts-expect-error I don't know how to make this typed properly 137 | {keys: [item => item.name.first]}, 138 | ], 139 | output: [{name: {first: 'bat'}}, {name: {first: 'baz'}}], 140 | }, 141 | 'can handle keys that are an array of values': { 142 | input: [ 143 | [ 144 | {favoriteIceCream: ['mint', 'chocolate']}, 145 | {favoriteIceCream: ['candy cane', 'brownie']}, 146 | {favoriteIceCream: ['birthday cake', 'rocky road', 'strawberry']}, 147 | ], 148 | 'cc', 149 | {keys: ['favoriteIceCream']}, 150 | ], 151 | output: [ 152 | {favoriteIceCream: ['candy cane', 'brownie']}, 153 | {favoriteIceCream: ['mint', 'chocolate']}, 154 | ], 155 | }, 156 | 'can handle keys with a maxRanking': { 157 | input: [ 158 | [ 159 | {tea: 'Earl Grey', alias: 'A'}, 160 | {tea: 'Assam', alias: 'B'}, 161 | {tea: 'Black', alias: 'C'}, 162 | ], 163 | 'A', 164 | { 165 | keys: ['tea', {maxRanking: rankings.STARTS_WITH, key: 'alias'}], 166 | }, 167 | ], 168 | // without maxRanking, Earl Grey would come first because the alias "A" would be CASE_SENSITIVE_EQUAL 169 | // `tea` key comes before `alias` key, so Assam comes first even though both match as STARTS_WITH 170 | output: [ 171 | {tea: 'Assam', alias: 'B'}, 172 | {tea: 'Earl Grey', alias: 'A'}, 173 | {tea: 'Black', alias: 'C'}, 174 | ], 175 | }, 176 | 'can handle keys with a minRanking': { 177 | input: [ 178 | [ 179 | {tea: 'Milk', alias: 'moo'}, 180 | {tea: 'Oolong', alias: 'B'}, 181 | {tea: 'Green', alias: 'C'}, 182 | ], 183 | 'oo', 184 | {keys: ['tea', {minRanking: rankings.EQUAL, key: 'alias'}]}, 185 | ], 186 | // minRanking bumps Milk up to EQUAL from CONTAINS (alias) 187 | // Oolong matches as STARTS_WITH 188 | // Green is missing due to no match 189 | output: [ 190 | {tea: 'Milk', alias: 'moo'}, 191 | {tea: 'Oolong', alias: 'B'}, 192 | ], 193 | }, 194 | 'when using arrays of values, when things are equal, the one with the higher key index wins': { 195 | input: [ 196 | [ 197 | {favoriteIceCream: ['mint', 'chocolate']}, 198 | {favoriteIceCream: ['chocolate', 'brownie']}, 199 | ], 200 | 'chocolate', 201 | {keys: ['favoriteIceCream']}, 202 | ], 203 | output: [ 204 | {favoriteIceCream: ['chocolate', 'brownie']}, 205 | {favoriteIceCream: ['mint', 'chocolate']}, 206 | ], 207 | }, 208 | 'when providing a rank threshold of NO_MATCH, it returns all of the items': { 209 | input: [ 210 | ['orange', 'apple', 'grape', 'banana'], 211 | 'ap', 212 | {threshold: rankings.NO_MATCH}, 213 | ], 214 | output: ['apple', 'grape', 'banana', 'orange'], 215 | }, 216 | 'when providing a rank threshold of EQUAL, it returns only the items that are equal': { 217 | input: [ 218 | ['google', 'airbnb', 'apple', 'apply', 'app'], 219 | 'app', 220 | {threshold: rankings.EQUAL}, 221 | ], 222 | output: ['app'], 223 | }, 224 | 'when providing a rank threshold of CASE_SENSITIVE_EQUAL, it returns only case-sensitive equal matches': { 225 | input: [ 226 | ['google', 'airbnb', 'apple', 'apply', 'app', 'aPp', 'App'], 227 | 'app', 228 | {threshold: rankings.CASE_SENSITIVE_EQUAL}, 229 | ], 230 | output: ['app'], 231 | }, 232 | 'when providing a rank threshold of WORD_STARTS_WITH, it returns only the items that are equal': { 233 | input: [ 234 | ['fiji apple', 'google', 'app', 'crabapple', 'apple', 'apply'], 235 | 'app', 236 | {threshold: rankings.WORD_STARTS_WITH}, 237 | ], 238 | output: ['app', 'apple', 'apply', 'fiji apple'], 239 | }, 240 | 'when providing a rank threshold of ACRONYM, it returns only the items that meet the rank': { 241 | input: [ 242 | ['apple', 'atop', 'alpaca', 'vamped'], 243 | 'ap', 244 | {threshold: rankings.ACRONYM}, 245 | ], 246 | output: ['apple'], 247 | }, 248 | 'defaults to ignore diacritics': { 249 | input: [ 250 | ['jalapeño', 'à la carte', 'café', 'papier-mâché', 'à la mode'], 251 | 'aa', 252 | ], 253 | output: ['jalapeño', 'à la carte', 'à la mode', 'papier-mâché'], 254 | }, 255 | 'takes diacritics in account when keepDiacritics specified as true': { 256 | input: [ 257 | ['jalapeño', 'à la carte', 'papier-mâché', 'à la mode'], 258 | 'aa', 259 | {keepDiacritics: true}, 260 | ], 261 | output: ['jalapeño', 'à la carte'], 262 | }, 263 | 'sorts items based on how closely they match': { 264 | input: [ 265 | ['Antigua and Barbuda', 'India', 'Bosnia and Herzegovina', 'Indonesia'], 266 | 'Ina', 267 | ], 268 | output: [ 269 | // these are sorted based on how closes their letters are to one another based on the input 270 | // contains 2 6 8 271 | 'Bosnia and Herzegovina', 272 | 'India', 273 | 'Indonesia', 274 | 'Antigua and Barbuda', 275 | // though, technically, `India` comes up first because it matches with STARTS_WITH... 276 | ], 277 | }, 278 | 'sort when search value is absent': { 279 | input: [ 280 | [ 281 | {tea: 'Milk', alias: 'moo'}, 282 | {tea: 'Oolong', alias: 'B'}, 283 | {tea: 'Green', alias: 'C'}, 284 | ], 285 | '', 286 | {keys: ['tea']}, 287 | ], 288 | output: [ 289 | {tea: 'Green', alias: 'C'}, 290 | {tea: 'Milk', alias: 'moo'}, 291 | {tea: 'Oolong', alias: 'B'}, 292 | ], 293 | }, 294 | 'only match when key meets threshold': { 295 | input: [ 296 | [ 297 | {name: 'Fred', color: 'Orange'}, 298 | {name: 'Jen', color: 'Red'}, 299 | ], 300 | 'ed', 301 | { 302 | keys: [{threshold: rankings.STARTS_WITH, key: 'name'}, 'color'], 303 | }, 304 | ], 305 | output: [{name: 'Jen', color: 'Red'}], 306 | }, 307 | 'should match when key threshold is lower than the default threshold': { 308 | input: [ 309 | [ 310 | {name: 'Fred', color: 'Orange'}, 311 | {name: 'Jen', color: 'Red'}, 312 | ], 313 | 'ed', 314 | { 315 | keys: ['name', {threshold: rankings.CONTAINS, key: 'color'}], 316 | threshold: rankings.STARTS_WITH, 317 | }, 318 | ], 319 | output: [{name: 'Jen', color: 'Red'}], 320 | }, 321 | 'case insensitive cyrillic match': { 322 | input: [['Привет', 'Лед'], 'л'], 323 | output: ['Лед'], 324 | }, 325 | 'should sort same ranked items alphabetically while when mixed with diacritics': { 326 | input: [ 327 | [ 328 | 'jalapeño', 329 | 'anothernodiacritics', 330 | 'à la carte', 331 | 'nodiacritics', 332 | 'café', 333 | 'papier-mâché', 334 | 'à la mode', 335 | ], 336 | 'z', 337 | { 338 | threshold: rankings.NO_MATCH, 339 | }, 340 | ], 341 | output: [ 342 | 'à la carte', 343 | 'à la mode', 344 | 'anothernodiacritics', 345 | 'café', 346 | 'jalapeño', 347 | 'nodiacritics', 348 | 'papier-mâché', 349 | ], 350 | }, 351 | 'returns objects in their original order': { 352 | input: [ 353 | [ 354 | {country: 'Italy', counter: 3}, 355 | {country: 'Italy', counter: 2}, 356 | {country: 'Italy', counter: 1}, 357 | ], 358 | 'Italy', 359 | {keys: ['country', 'counter']}, 360 | ], 361 | output: [ 362 | {country: 'Italy', counter: 3}, 363 | {country: 'Italy', counter: 2}, 364 | {country: 'Italy', counter: 1}, 365 | ], 366 | }, 367 | 'supports a custom baseSort function for tie-breakers': { 368 | input: [ 369 | ['appl', 'C apple', 'B apple', 'A apple', 'app', 'applebutter'], 370 | 'apple', 371 | {baseSort: (a, b) => (a.index < b.index ? -1 : 1)}, 372 | ], 373 | output: ['applebutter', 'C apple', 'B apple', 'A apple'], 374 | }, 375 | 'sorts simple items alphabetically': { 376 | input: [[`a'd`, 'a-c', 'a_b', 'a a'], ''], 377 | output: ['a a', 'a_b', 'a-c', `a'd`], 378 | }, 379 | 'can work around non space separated words': { 380 | input: [ 381 | [ 382 | {name: 'Janice_Kurtis'}, 383 | {name: 'Fred_Mertz'}, 384 | {name: 'George_Foreman'}, 385 | {name: 'Jen_Smith'}, 386 | ], 387 | 'js', 388 | // @ts-expect-error I don't know how to make this typed properly 389 | {keys: [item => item.name.replace(/_/g, ' ')]}, 390 | ], 391 | output: [{name: 'Jen_Smith'}, {name: 'Janice_Kurtis'}], 392 | }, 393 | } 394 | 395 | for (const [ 396 | title, 397 | {input, output, only = false, skip = false}, 398 | ] of Object.entries(tests)) { 399 | const testFn = () => expect(matchSorter(...input)).toEqual(output) 400 | 401 | if (only) { 402 | test.only(title, testFn) 403 | } else if (skip) { 404 | test.skip(title, testFn) 405 | } else { 406 | test(title, testFn) 407 | } 408 | } 409 | 410 | /* 411 | eslint 412 | jest/valid-title: "off", 413 | jest/no-disabled-tests: "off", 414 | jest/no-focused-tests: "off", 415 | @typescript-eslint/no-unsafe-call: "off", 416 | @typescript-eslint/no-unsafe-member-access: "off" 417 | */ 418 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @name match-sorter 3 | * @license MIT license. 4 | * @copyright (c) 2020 Kent C. Dodds 5 | * @author Kent C. Dodds (https://kentcdodds.com) 6 | */ 7 | import removeAccents from 'remove-accents' 8 | 9 | type KeyAttributes = { 10 | threshold?: number 11 | maxRanking: number 12 | minRanking: number 13 | } 14 | interface RankingInfo { 15 | rankedValue: string 16 | rank: number 17 | keyIndex: number 18 | keyThreshold: number | undefined 19 | } 20 | 21 | interface ValueGetterKey { 22 | (item: ItemType): string | Array 23 | } 24 | interface IndexedItem { 25 | item: ItemType 26 | index: number 27 | } 28 | interface RankedItem extends RankingInfo, IndexedItem {} 29 | 30 | interface BaseSorter { 31 | (a: RankedItem, b: RankedItem): number 32 | } 33 | 34 | interface KeyAttributesOptions { 35 | key?: string | ValueGetterKey 36 | threshold?: number 37 | maxRanking?: number 38 | minRanking?: number 39 | } 40 | 41 | type KeyOption = 42 | | KeyAttributesOptions 43 | | ValueGetterKey 44 | | string 45 | 46 | // ItemType = unknown allowed me to make these changes without the need to change the current tests 47 | interface MatchSorterOptions { 48 | keys?: Array> 49 | threshold?: number 50 | baseSort?: BaseSorter 51 | keepDiacritics?: boolean 52 | } 53 | 54 | const rankings = { 55 | CASE_SENSITIVE_EQUAL: 7, 56 | EQUAL: 6, 57 | STARTS_WITH: 5, 58 | WORD_STARTS_WITH: 4, 59 | CONTAINS: 3, 60 | ACRONYM: 2, 61 | MATCHES: 1, 62 | NO_MATCH: 0, 63 | } 64 | 65 | matchSorter.rankings = rankings 66 | 67 | const defaultBaseSortFn: BaseSorter = (a, b) => 68 | String(a.rankedValue).localeCompare(String(b.rankedValue)) 69 | 70 | /** 71 | * Takes an array of items and a value and returns a new array with the items that match the given value 72 | * @param {Array} items - the items to sort 73 | * @param {String} value - the value to use for ranking 74 | * @param {Object} options - Some options to configure the sorter 75 | * @return {Array} - the new sorted array 76 | */ 77 | function matchSorter( 78 | items: Array, 79 | value: string, 80 | options: MatchSorterOptions = {}, 81 | ): Array { 82 | const { 83 | keys, 84 | threshold = rankings.MATCHES, 85 | baseSort = defaultBaseSortFn, 86 | } = options 87 | const matchedItems = items.reduce(reduceItemsToRanked, []) 88 | return matchedItems 89 | .sort((a, b) => sortRankedValues(a, b, baseSort)) 90 | .map(({item}) => item) 91 | 92 | function reduceItemsToRanked( 93 | matches: Array>, 94 | item: ItemType, 95 | index: number, 96 | ): Array> { 97 | const rankingInfo = getHighestRanking(item, keys, value, options) 98 | const {rank, keyThreshold = threshold} = rankingInfo 99 | if (rank >= keyThreshold) { 100 | matches.push({...rankingInfo, item, index}) 101 | } 102 | return matches 103 | } 104 | } 105 | 106 | /** 107 | * Gets the highest ranking for value for the given item based on its values for the given keys 108 | * @param {*} item - the item to rank 109 | * @param {Array} keys - the keys to get values from the item for the ranking 110 | * @param {String} value - the value to rank against 111 | * @param {Object} options - options to control the ranking 112 | * @return {{rank: Number, keyIndex: Number, keyThreshold: Number}} - the highest ranking 113 | */ 114 | function getHighestRanking( 115 | item: ItemType, 116 | keys: Array> | undefined, 117 | value: string, 118 | options: MatchSorterOptions, 119 | ): RankingInfo { 120 | if (!keys) { 121 | // if keys is not specified, then we assume the item given is ready to be matched 122 | const stringItem = (item as unknown) as string 123 | return { 124 | // ends up being duplicate of 'item' in matches but consistent 125 | rankedValue: stringItem, 126 | rank: getMatchRanking(stringItem, value, options), 127 | keyIndex: -1, 128 | keyThreshold: options.threshold, 129 | } 130 | } 131 | const valuesToRank = getAllValuesToRank(item, keys) 132 | return valuesToRank.reduce( 133 | ( 134 | {rank, rankedValue, keyIndex, keyThreshold}, 135 | {itemValue, attributes}, 136 | i, 137 | ) => { 138 | let newRank = getMatchRanking(itemValue, value, options) 139 | let newRankedValue = rankedValue 140 | const {minRanking, maxRanking, threshold} = attributes 141 | if (newRank < minRanking && newRank >= rankings.MATCHES) { 142 | newRank = minRanking 143 | } else if (newRank > maxRanking) { 144 | newRank = maxRanking 145 | } 146 | if (newRank > rank) { 147 | rank = newRank 148 | keyIndex = i 149 | keyThreshold = threshold 150 | newRankedValue = itemValue 151 | } 152 | return {rankedValue: newRankedValue, rank, keyIndex, keyThreshold} 153 | }, 154 | { 155 | rankedValue: (item as unknown) as string, 156 | rank: rankings.NO_MATCH, 157 | keyIndex: -1, 158 | keyThreshold: options.threshold, 159 | }, 160 | ) 161 | } 162 | 163 | /** 164 | * Gives a rankings score based on how well the two strings match. 165 | * @param {String} testString - the string to test against 166 | * @param {String} stringToRank - the string to rank 167 | * @param {Object} options - options for the match (like keepDiacritics for comparison) 168 | * @returns {Number} the ranking for how well stringToRank matches testString 169 | */ 170 | function getMatchRanking( 171 | testString: string, 172 | stringToRank: string, 173 | options: MatchSorterOptions, 174 | ): number { 175 | testString = prepareValueForComparison(testString, options) 176 | stringToRank = prepareValueForComparison(stringToRank, options) 177 | 178 | // too long 179 | if (stringToRank.length > testString.length) { 180 | return rankings.NO_MATCH 181 | } 182 | 183 | // case sensitive equals 184 | if (testString === stringToRank) { 185 | return rankings.CASE_SENSITIVE_EQUAL 186 | } 187 | 188 | // Lower casing before further comparison 189 | testString = testString.toLowerCase() 190 | stringToRank = stringToRank.toLowerCase() 191 | 192 | // case insensitive equals 193 | if (testString === stringToRank) { 194 | return rankings.EQUAL 195 | } 196 | 197 | // starts with 198 | if (testString.startsWith(stringToRank)) { 199 | return rankings.STARTS_WITH 200 | } 201 | 202 | // word starts with 203 | if (testString.includes(` ${stringToRank}`)) { 204 | return rankings.WORD_STARTS_WITH 205 | } 206 | 207 | // contains 208 | if (testString.includes(stringToRank)) { 209 | return rankings.CONTAINS 210 | } else if (stringToRank.length === 1) { 211 | // If the only character in the given stringToRank 212 | // isn't even contained in the testString, then 213 | // it's definitely not a match. 214 | return rankings.NO_MATCH 215 | } 216 | 217 | // acronym 218 | if (getAcronym(testString).includes(stringToRank)) { 219 | return rankings.ACRONYM 220 | } 221 | 222 | // will return a number between rankings.MATCHES and 223 | // rankings.MATCHES + 1 depending on how close of a match it is. 224 | return getClosenessRanking(testString, stringToRank) 225 | } 226 | 227 | /** 228 | * Generates an acronym for a string. 229 | * 230 | * @param {String} string the string for which to produce the acronym 231 | * @returns {String} the acronym 232 | */ 233 | function getAcronym(string: string): string { 234 | let acronym = '' 235 | const wordsInString = string.split(' ') 236 | wordsInString.forEach(wordInString => { 237 | const splitByHyphenWords = wordInString.split('-') 238 | splitByHyphenWords.forEach(splitByHyphenWord => { 239 | acronym += splitByHyphenWord.substr(0, 1) 240 | }) 241 | }) 242 | return acronym 243 | } 244 | 245 | /** 246 | * Returns a score based on how spread apart the 247 | * characters from the stringToRank are within the testString. 248 | * A number close to rankings.MATCHES represents a loose match. A number close 249 | * to rankings.MATCHES + 1 represents a tighter match. 250 | * @param {String} testString - the string to test against 251 | * @param {String} stringToRank - the string to rank 252 | * @returns {Number} the number between rankings.MATCHES and 253 | * rankings.MATCHES + 1 for how well stringToRank matches testString 254 | */ 255 | function getClosenessRanking(testString: string, stringToRank: string): number { 256 | let matchingInOrderCharCount = 0 257 | let charNumber = 0 258 | function findMatchingCharacter( 259 | matchChar: string, 260 | string: string, 261 | index: number, 262 | ) { 263 | for (let j = index; j < string.length; j++) { 264 | const stringChar = string[j] 265 | if (stringChar === matchChar) { 266 | matchingInOrderCharCount += 1 267 | return j + 1 268 | } 269 | } 270 | return -1 271 | } 272 | function getRanking(spread: number) { 273 | const spreadPercentage = 1 / spread 274 | const inOrderPercentage = matchingInOrderCharCount / stringToRank.length 275 | const ranking = rankings.MATCHES + inOrderPercentage * spreadPercentage 276 | return ranking 277 | } 278 | const firstIndex = findMatchingCharacter(stringToRank[0], testString, 0) 279 | if (firstIndex < 0) { 280 | return rankings.NO_MATCH 281 | } 282 | charNumber = firstIndex 283 | for (let i = 1; i < stringToRank.length; i++) { 284 | const matchChar = stringToRank[i] 285 | charNumber = findMatchingCharacter(matchChar, testString, charNumber) 286 | const found = charNumber > -1 287 | if (!found) { 288 | return rankings.NO_MATCH 289 | } 290 | } 291 | 292 | const spread = charNumber - firstIndex 293 | return getRanking(spread) 294 | } 295 | 296 | /** 297 | * Sorts items that have a rank, index, and keyIndex 298 | * @param {Object} a - the first item to sort 299 | * @param {Object} b - the second item to sort 300 | * @return {Number} -1 if a should come first, 1 if b should come first, 0 if equal 301 | */ 302 | function sortRankedValues( 303 | a: RankedItem, 304 | b: RankedItem, 305 | baseSort: BaseSorter, 306 | ): number { 307 | const aFirst = -1 308 | const bFirst = 1 309 | const {rank: aRank, keyIndex: aKeyIndex} = a 310 | const {rank: bRank, keyIndex: bKeyIndex} = b 311 | const same = aRank === bRank 312 | if (same) { 313 | if (aKeyIndex === bKeyIndex) { 314 | // use the base sort function as a tie-breaker 315 | return baseSort(a, b) 316 | } else { 317 | return aKeyIndex < bKeyIndex ? aFirst : bFirst 318 | } 319 | } else { 320 | return aRank > bRank ? aFirst : bFirst 321 | } 322 | } 323 | 324 | /** 325 | * Prepares value for comparison by stringifying it, removing diacritics (if specified) 326 | * @param {String} value - the value to clean 327 | * @param {Object} options - {keepDiacritics: whether to remove diacritics} 328 | * @return {String} the prepared value 329 | */ 330 | function prepareValueForComparison( 331 | value: string, 332 | {keepDiacritics}: MatchSorterOptions, 333 | ): string { 334 | // value might not actually be a string at this point (we don't get to choose) 335 | // so part of preparing the value for comparison is ensure that it is a string 336 | value = `${value}` // toString 337 | if (!keepDiacritics) { 338 | value = removeAccents(value) 339 | } 340 | return value 341 | } 342 | 343 | /** 344 | * Gets value for key in item at arbitrarily nested keypath 345 | * @param {Object} item - the item 346 | * @param {Object|Function} key - the potentially nested keypath or property callback 347 | * @return {Array} - an array containing the value(s) at the nested keypath 348 | */ 349 | function getItemValues( 350 | item: ItemType, 351 | key: KeyOption, 352 | ): Array | null { 353 | if (typeof key === 'object') { 354 | key = key.key as string 355 | } 356 | let value: string | Array | null 357 | if (typeof key === 'function') { 358 | value = key(item) 359 | // eslint-disable-next-line no-negated-condition 360 | } else { 361 | value = getNestedValue(key, item) 362 | } 363 | const values: Array = [] 364 | // concat because `value` can be a string or an array 365 | // eslint-disable-next-line 366 | return value != null ? values.concat(value) : null 367 | } 368 | 369 | /** 370 | * Given key: "foo.bar.baz" 371 | * And obj: {foo: {bar: {baz: 'buzz'}}} 372 | * -> 'buzz' 373 | * @param key a dot-separated set of keys 374 | * @param obj the object to get the value from 375 | */ 376 | function getNestedValue( 377 | key: string, 378 | obj: ItemType, 379 | ): string | Array | null { 380 | // @ts-expect-error really have no idea how to type this properly... 381 | return key.split('.').reduce((itemObj: object | null, nestedKey: string): 382 | | object 383 | | string 384 | | null => { 385 | // @ts-expect-error lost on this one as well... 386 | return itemObj ? itemObj[nestedKey] : null 387 | }, obj) 388 | } 389 | 390 | /** 391 | * Gets all the values for the given keys in the given item and returns an array of those values 392 | * @param item - the item from which the values will be retrieved 393 | * @param keys - the keys to use to retrieve the values 394 | * @return objects with {itemValue, attributes} 395 | */ 396 | function getAllValuesToRank( 397 | item: ItemType, 398 | keys: Array>, 399 | ) { 400 | return keys.reduce>( 401 | (allVals, key) => { 402 | const values = getItemValues(item, key) 403 | if (values) { 404 | values.forEach(itemValue => { 405 | allVals.push({ 406 | itemValue, 407 | attributes: getKeyAttributes(key), 408 | }) 409 | }) 410 | } 411 | return allVals 412 | }, 413 | [], 414 | ) 415 | } 416 | 417 | const defaultKeyAttributes = { 418 | maxRanking: Infinity, 419 | minRanking: -Infinity, 420 | } 421 | /** 422 | * Gets all the attributes for the given key 423 | * @param key - the key from which the attributes will be retrieved 424 | * @return object containing the key's attributes 425 | */ 426 | function getKeyAttributes(key: KeyOption): KeyAttributes { 427 | if (typeof key === 'string') { 428 | return defaultKeyAttributes 429 | } 430 | return {...defaultKeyAttributes, ...key} 431 | } 432 | 433 | export {matchSorter, rankings, defaultBaseSortFn} 434 | 435 | export type { 436 | MatchSorterOptions, 437 | KeyAttributesOptions, 438 | KeyOption, 439 | KeyAttributes, 440 | RankingInfo, 441 | ValueGetterKey, 442 | } 443 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

match-sorter

3 | 4 |

Simple, expected, and deterministic best-match sorting of an array in JavaScript

5 |
6 | 7 | --- 8 | 9 | **[Demo](https://codesandbox.io/s/wyk856yo48)** 10 | 11 | 12 | [![Build Status][build-badge]][build] 13 | [![Code Coverage][coverage-badge]][coverage] 14 | [![version][version-badge]][package] 15 | [![downloads][downloads-badge]][npmtrends] 16 | [![MIT License][license-badge]][license] 17 | [![All Contributors][all-contributors-badge]](#contributors-) 18 | [![PRs Welcome][prs-badge]][prs] 19 | [![Code of Conduct][coc-badge]][coc] 20 | [![Examples][examples-badge]][examples] 21 | 22 | 23 | ## The problem 24 | 25 | 1. You have a list of dozens, hundreds, or thousands of items 26 | 2. You want to filter and sort those items intelligently (maybe you have a 27 | filter input for the user) 28 | 3. You want simple, expected, and deterministic sorting of the items (no fancy 29 | math algorithm that fancily changes the sorting as they type) 30 | 31 | ## This solution 32 | 33 | This follows a simple and sensible (user friendly) algorithm that makes it easy 34 | for you to filter and sort a list of items based on given input. Items are 35 | ranked based on sensible criteria that result in a better user experience. 36 | 37 | To explain the ranking system, I'll use countries as an example: 38 | 39 | 1. **CASE SENSITIVE EQUALS**: Case-sensitive equality trumps all. These will be 40 | first. (ex. `France` would match `France`, but not `france`) 41 | 2. **EQUALS**: Case-insensitive equality (ex. `France` would match `france`) 42 | 3. **STARTS WITH**: If the item starts with the given value (ex. `Sou` would 43 | match `South Korea` or `South Africa`) 44 | 4. **WORD STARTS WITH**: If the item has multiple words, then if one of those 45 | words starts with the given value (ex. `Repub` would match 46 | `Dominican Republic`) 47 | 5. **CONTAINS**: If the item contains the given value (ex. `ham` would match 48 | `Bahamas`) 49 | 6. **ACRONYM**: If the item's acronym is the given value (ex. `us` would match 50 | `United States`) 51 | 7. **SIMPLE MATCH**: If the item has letters in the same order as the letters 52 | of the given value (ex. `iw` would match `Zimbabwe`, but not `Kuwait` 53 | because it must be in the same order). Furthermore, if the item is a closer 54 | match, it will rank higher (ex. `ua` matches `Uruguay` more closely than 55 | `United States of America`, therefore `Uruguay` will be ordered before 56 | `United States of America`) 57 | 58 | This ranking seems to make sense in people's minds. At least it does in mine. 59 | Feedback welcome! 60 | 61 | 62 | 63 | 64 | - [Installation](#installation) 65 | - [Usage](#usage) 66 | - [Advanced options](#advanced-options) 67 | - [keys: `[string]`](#keys-string) 68 | - [threshold: `number`](#threshold-number) 69 | - [keepDiacritics: `boolean`](#keepdiacritics-boolean) 70 | - [baseSort: `function(itemA, itemB): -1 | 0 | 1`](#basesort-functionitema-itemb--1--0--1) 71 | - [Recipes](#recipes) 72 | - [Match PascalCase, camelCase, snake_case, or kebab-case as words](#match-pascalcase-camelcase-snake_case-or-kebab-case-as-words) 73 | - [Match many words across multiple fields (table filtering)](#match-many-words-across-multiple-fields-table-filtering) 74 | - [Inspiration](#inspiration) 75 | - [Other Solutions](#other-solutions) 76 | - [Issues](#issues) 77 | - [🐛 Bugs](#-bugs) 78 | - [💡 Feature Requests](#-feature-requests) 79 | - [Contributors ✨](#contributors-) 80 | - [LICENSE](#license) 81 | 82 | 83 | 84 | ## Installation 85 | 86 | This module is distributed via [npm][npm] which is bundled with [node][node] and 87 | should be installed as one of your project's `dependencies`: 88 | 89 | ``` 90 | npm install match-sorter 91 | ``` 92 | 93 | ## Usage 94 | 95 | ```javascript 96 | import {matchSorter} from 'match-sorter' 97 | // or const {matchSorter} = require('match-sorter') 98 | // or window.matchSorter.matchSorter 99 | const list = ['hi', 'hey', 'hello', 'sup', 'yo'] 100 | matchSorter(list, 'h') // ['hello', 'hey', 'hi'] 101 | matchSorter(list, 'y') // ['yo', 'hey'] 102 | matchSorter(list, 'z') // [] 103 | ``` 104 | 105 | ## Advanced options 106 | 107 | ### keys: `[string]` 108 | 109 | _Default: `undefined`_ 110 | 111 | By default it just uses the value itself as above. Passing an array tells 112 | match-sorter which keys to use for the ranking. 113 | 114 | ```javascript 115 | const objList = [ 116 | {name: 'Janice', color: 'Green'}, 117 | {name: 'Fred', color: 'Orange'}, 118 | {name: 'George', color: 'Blue'}, 119 | {name: 'Jen', color: 'Red'}, 120 | ] 121 | matchSorter(objList, 'g', {keys: ['name', 'color']}) 122 | // [{name: 'George', color: 'Blue'}, {name: 'Janice', color: 'Green'}, {name: 'Fred', color: 'Orange'}] 123 | 124 | matchSorter(objList, 're', {keys: ['color', 'name']}) 125 | // [{name: 'Jen', color: 'Red'}, {name: 'Janice', color: 'Green'}, {name: 'Fred', color: 'Orange'}, {name: 'George', color: 'Blue'}] 126 | ``` 127 | 128 | **Array of values**: When the specified key matches an array of values, the best 129 | match from the values of in the array is going to be used for the ranking. 130 | 131 | ```javascript 132 | const iceCreamYum = [ 133 | {favoriteIceCream: ['mint', 'chocolate']}, 134 | {favoriteIceCream: ['candy cane', 'brownie']}, 135 | {favoriteIceCream: ['birthday cake', 'rocky road', 'strawberry']}, 136 | ] 137 | matchSorter(iceCreamYum, 'cc', {keys: ['favoriteIceCream']}) 138 | // [{favoriteIceCream: ['candy cane', 'brownie']}, {favoriteIceCream: ['mint', 'chocolate']}] 139 | ``` 140 | 141 | **Nested Keys**: You can specify nested keys using dot-notation. 142 | 143 | ```javascript 144 | const nestedObjList = [ 145 | {name: {first: 'Janice'}}, 146 | {name: {first: 'Fred'}}, 147 | {name: {first: 'George'}}, 148 | {name: {first: 'Jen'}}, 149 | ] 150 | matchSorter(nestedObjList, 'j', {keys: ['name.first']}) 151 | // [{name: {first: 'Janice'}}, {name: {first: 'Jen'}}] 152 | 153 | const nestedObjList = [ 154 | {name: [{first: 'Janice'}]}, 155 | {name: [{first: 'Fred'}]}, 156 | {name: [{first: 'George'}]}, 157 | {name: [{first: 'Jen'}]}, 158 | ] 159 | matchSorter(nestedObjList, 'j', {keys: ['name.0.first']}) 160 | // [{name: {first: 'Janice'}}, {name: {first: 'Jen'}}] 161 | // matchSorter(nestedObjList, 'j', {keys: ['name[0].first']}) does not work 162 | ``` 163 | 164 | **Property Callbacks**: Alternatively, you may also pass in a callback function 165 | that resolves the value of the key(s) you wish to match on. This is especially 166 | useful when interfacing with libraries such as Immutable.js 167 | 168 | ```javascript 169 | const list = [{name: 'Janice'}, {name: 'Fred'}, {name: 'George'}, {name: 'Jen'}] 170 | matchSorter(list, 'j', {keys: [item => item.name]}) 171 | // [{name: 'Janice'}, {name: 'Jen'}] 172 | ``` 173 | 174 | For more complex structures, expanding on the `nestedObjList` example above, you 175 | can use `map`: 176 | 177 | ```javascript 178 | const nestedObjList = [ 179 | { 180 | name: [ 181 | {first: 'Janice', last: 'Smith'}, 182 | {first: 'Jon', last: 'Doe'}, 183 | ], 184 | }, 185 | { 186 | name: [ 187 | {first: 'Fred', last: 'Astaire'}, 188 | {first: 'Jenny', last: 'Doe'}, 189 | {first: 'Wilma', last: 'Flintstone'}, 190 | ], 191 | }, 192 | ] 193 | matchSorter(nestedObjList, 'doe', { 194 | keys: [ 195 | item => item.name.map(i => i.first), 196 | item => item.name.map(i => i.last), 197 | ], 198 | }) 199 | // [name: [{ first: 'Janice', last: 'Smith' },{ first: 'Jon', last: 'Doe' }], name: [{ first: 'Fred', last: 'Astaire' },{ first: 'Jenny', last: 'Doe' },{ first: 'Wilma', last: 'Flintstone' }]] 200 | ``` 201 | 202 | **Threshold**: You may specify an individual threshold for specific keys. A key 203 | will only match if it meets the specified threshold. _For more information 204 | regarding thresholds [see below](#threshold-number)_ 205 | 206 | ```javascript 207 | const list = [ 208 | {name: 'Fred', color: 'Orange'}, 209 | {name: 'Jen', color: 'Red'}, 210 | ] 211 | matchSorter(list, 'ed', { 212 | keys: [{threshold: matchSorter.rankings.STARTS_WITH, key: 'name'}, 'color'], 213 | }) 214 | //[{name: 'Jen', color: 'Red'}] 215 | ``` 216 | 217 | **Min and Max Ranking**: You may restrict specific keys to a minimum or maximum 218 | ranking by passing in an object. A key with a minimum rank will only get 219 | promoted if there is at least a simple match. 220 | 221 | ```javascript 222 | const tea = [ 223 | {tea: 'Earl Grey', alias: 'A'}, 224 | {tea: 'Assam', alias: 'B'}, 225 | {tea: 'Black', alias: 'C'}, 226 | ] 227 | matchSorter(tea, 'A', { 228 | keys: ['tea', {maxRanking: matchSorter.rankings.STARTS_WITH, key: 'alias'}], 229 | }) 230 | // without maxRanking, Earl Grey would come first because the alias "A" would be CASE_SENSITIVE_EQUAL 231 | // `tea` key comes before `alias` key, so Assam comes first even though both match as STARTS_WITH 232 | // [{tea: 'Assam', alias: 'B'}, {tea: 'Earl Grey', alias: 'A'},{tea: 'Black', alias: 'C'}] 233 | ``` 234 | 235 | ```javascript 236 | const tea = [ 237 | {tea: 'Milk', alias: 'moo'}, 238 | {tea: 'Oolong', alias: 'B'}, 239 | {tea: 'Green', alias: 'C'}, 240 | ] 241 | matchSorter(tea, 'oo', { 242 | keys: ['tea', {minRanking: matchSorter.rankings.EQUAL, key: 'alias'}], 243 | }) 244 | // minRanking bumps Milk up to EQUAL from CONTAINS (alias) 245 | // Oolong matches as STARTS_WITH 246 | // Green is missing due to no match 247 | // [{tea: 'Milk', alias: 'moo'}, {tea: 'Oolong', alias: 'B'}] 248 | ``` 249 | 250 | ### threshold: `number` 251 | 252 | _Default: `MATCHES`_ 253 | 254 | Thresholds can be used to specify the criteria used to rank the results. 255 | Available thresholds (from top to bottom) are: 256 | 257 | - CASE_SENSITIVE_EQUAL 258 | - EQUAL 259 | - STARTS_WITH 260 | - WORD_STARTS_WITH 261 | - STRING_CASE 262 | - STRING_CASE_ACRONYM 263 | - CONTAINS 264 | - ACRONYM 265 | - MATCHES _(default value)_ 266 | - NO_MATCH 267 | 268 | ```javascript 269 | const fruit = ['orange', 'apple', 'grape', 'banana'] 270 | matchSorter(fruit, 'ap', {threshold: matchSorter.rankings.NO_MATCH}) 271 | // ['apple', 'grape', 'orange', 'banana'] (returns all items, just sorted by best match) 272 | 273 | const things = ['google', 'airbnb', 'apple', 'apply', 'app'], 274 | matchSorter(things, 'app', {threshold: matchSorter.rankings.EQUAL}) 275 | // ['app'] (only items that are equal) 276 | 277 | const otherThings = ['fiji apple', 'google', 'app', 'crabapple', 'apple', 'apply'] 278 | matchSorter(otherThings, 'app', {threshold: matchSorter.rankings.WORD_STARTS_WITH}) 279 | // ['app', 'apple', 'apply', 'fiji apple'] (everything that matches with "word starts with" or better) 280 | ``` 281 | 282 | ### keepDiacritics: `boolean` 283 | 284 | _Default: `false`_ 285 | 286 | By default, match-sorter will strip diacritics before doing any comparisons. 287 | This is the default because it makes the most sense from a UX perspective. 288 | 289 | You can disable this behavior by specifying `keepDiacritics: true` 290 | 291 | ```javascript 292 | const thingsWithDiacritics = [ 293 | 'jalapeño', 294 | 'à la carte', 295 | 'café', 296 | 'papier-mâché', 297 | 'à la mode', 298 | ] 299 | matchSorter(thingsWithDiacritics, 'aa') 300 | // ['jalapeño', 'à la carte', 'à la mode', 'papier-mâché'] 301 | 302 | matchSorter(thingsWithDiacritics, 'aa', {keepDiacritics: true}) 303 | // ['jalapeño', 'à la carte'] 304 | 305 | matchSorter(thingsWithDiacritics, 'à', {keepDiacritics: true}) 306 | // ['à la carte', 'à la mode'] 307 | ``` 308 | 309 | ### baseSort: `function(itemA, itemB): -1 | 0 | 1` 310 | 311 | _Default: `(a, b) => String(a.rankedValue).localeCompare(b.rankedValue)`_ 312 | 313 | By default, match-sorter uses the `String.localeCompare` function to tie-break 314 | items that have the same ranking. This results in a stable, alphabetic sort. 315 | 316 | ```javascript 317 | const list = ['C apple', 'B apple', 'A apple'] 318 | matchSorter(list, 'apple') 319 | // ['A apple', 'B apple', 'C apple'] 320 | ``` 321 | 322 | _You can customize this behavior by specifying a custom `baseSort` function:_ 323 | 324 | ```javascript 325 | const list = ['C apple', 'B apple', 'A apple'] 326 | // This baseSort function will use the original index of items as the tie breaker 327 | matchSorter(list, 'apple', {baseSort: (a, b) => (a.index < b.index ? -1 : 1)}) 328 | // ['C apple', 'B apple', 'A apple'] 329 | ``` 330 | 331 | ## Recipes 332 | 333 | ### Match PascalCase, camelCase, snake_case, or kebab-case as words 334 | 335 | By default, `match-sorter` assumes spaces to be the word separator. However, if 336 | your data has a different word separator, you can use a property callback to 337 | replace your separator with spaces. For example, for `snake_case`: 338 | 339 | ```javascript 340 | const list = [ 341 | {name: 'Janice_Kurtis'}, 342 | {name: 'Fred_Mertz'}, 343 | {name: 'George_Foreman'}, 344 | {name: 'Jen_Smith'}, 345 | ] 346 | matchSorter(list, 'js', {keys: [item => item.name.replace(/_/g, ' ')]}) 347 | // [{name: 'Jen_Smith'}, {name: 'Janice_Kurtis'}] 348 | ``` 349 | 350 | ### Match many words across multiple fields (table filtering) 351 | 352 | By default, `match-sorter` will return matches from objects where one of the 353 | properties matches _the entire_ search term. For multi-column data sets it can 354 | be beneficial to split words in search string and match each word separately. 355 | This can be done by chaining `match-sorter` calls. 356 | 357 | The benefit of this is that a filter string of "two words" will match both "two" 358 | and "words", but will return rows where the two words are found in _different_ 359 | columns as well as when both words match in the same column. For single-column 360 | matches it will also return matches out of order (column = "wordstwo" will match 361 | just as well as column="twowords", the latter getting a higher score). 362 | 363 | ```javascript 364 | function fuzzySearchMutipleWords( 365 | rows, // array of data [{a: "a", b: "b"}, {a: "c", b: "d"}] 366 | keys, // keys to search ["a", "b"] 367 | filterValue: string, // potentially multi-word search string "two words" 368 | ) { 369 | if (!filterValue || !filterValue.length) { 370 | return rows 371 | } 372 | 373 | const terms = filterValue.split(' ') 374 | if (!terms) { 375 | return rows 376 | } 377 | 378 | // reduceRight will mean sorting is done by score for the _first_ entered word. 379 | return terms.reduceRight( 380 | (results, term) => matchSorter(results, term, {keys}), 381 | rows, 382 | ) 383 | } 384 | ``` 385 | 386 | [Multi-column code sandbox](https://codesandbox.io/s/match-sorter-example-forked-1ko35) 387 | 388 | ## Inspiration 389 | 390 | Actually, most of this code was extracted from the _very first_ library I ever 391 | wrote: [genie][genie]! 392 | 393 | ## Other Solutions 394 | 395 | You might try [Fuse.js](https://github.com/krisk/Fuse). It uses advanced math 396 | fanciness to get the closest match. Unfortunately what's "closest" doesn't 397 | always really make sense. So I extracted this from [genie][genie]. 398 | 399 | ## Issues 400 | 401 | _Looking to contribute? Look for the [Good First Issue][good-first-issue] 402 | label._ 403 | 404 | ### 🐛 Bugs 405 | 406 | Please file an issue for bugs, missing documentation, or unexpected behavior. 407 | 408 | [**See Bugs**][bugs] 409 | 410 | ### 💡 Feature Requests 411 | 412 | Please file an issue to suggest new features. Vote on feature requests by adding 413 | a 👍. This helps maintainers prioritize what to work on. 414 | 415 | [**See Feature Requests**][requests] 416 | 417 | ## Contributors ✨ 418 | 419 | Thanks goes to these people ([emoji key][emojis]): 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 |

Kent C. Dodds

💻 📖 🚇 ⚠️ 👀

Conor Hastings

💻 📖 ⚠️ 👀

Rogelio Guzman

📖

Claudéric Demers

💻 📖 ⚠️

Kevin Davis

💻 ⚠️

Denver Chen

💻 📖 ⚠️

Christian Ruigrok

🐛 💻 📖

Hozefa

🐛 💻 ⚠️ 🤔

pushpinder107

💻

Mordy Tikotzky

💻 📖 ⚠️

Steven Brannum

💻 ⚠️

Christer van der Meeren

🐛

Samuel Petrosyan

💻 🐛

Brandon Kalinowski

🐛

Eric Berry

🔍

Skubie Doo

📖

Michaël De Boey

💻 👀

Tanner Linsley

💻

Victor

📖

Rebecca Stevens

🐛 📖

Marco Moretti

📖

Ricardo Busquet

🤔 👀 💻

Weyert de Boer

🤔 👀

Philipp Garbowsky

💻
458 | 459 | 460 | 461 | 462 | 463 | This project follows the [all-contributors][all-contributors] specification. 464 | Contributions of any kind welcome! 465 | 466 | ## LICENSE 467 | 468 | MIT 469 | 470 | 471 | [npm]: https://www.npmjs.com 472 | [node]: https://nodejs.org 473 | [build-badge]: https://img.shields.io/github/workflow/status/kentcdodds/match-sorter/validate?logo=github&style=flat-square 474 | [build]: https://github.com/kentcdodds/match-sorter/actions?query=workflow%3Avalidate 475 | [coverage-badge]: https://img.shields.io/codecov/c/github/kentcdodds/match-sorter.svg?style=flat-square 476 | [coverage]: https://codecov.io/github/kentcdodds/match-sorter 477 | [version-badge]: https://img.shields.io/npm/v/match-sorter.svg?style=flat-square 478 | [package]: https://www.npmjs.com/package/match-sorter 479 | [downloads-badge]: https://img.shields.io/npm/dm/match-sorter.svg?style=flat-square 480 | [npmtrends]: https://www.npmtrends.com/match-sorter 481 | [license-badge]: https://img.shields.io/npm/l/match-sorter.svg?style=flat-square 482 | [license]: https://github.com/kentcdodds/match-sorter/blob/master/LICENSE 483 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 484 | [prs]: http://makeapullrequest.com 485 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 486 | [coc]: https://github.com/kentcdodds/match-sorter/blob/master/CODE_OF_CONDUCT.md 487 | [examples-badge]: https://img.shields.io/badge/%F0%9F%92%A1-examples-8C8E93.svg?style=flat-square 488 | [examples]: https://github.com/kentcdodds/match-sorter/blob/master/other/EXAMPLES.md 489 | [emojis]: https://github.com/all-contributors/all-contributors#emoji-key 490 | [all-contributors]: https://github.com/all-contributors/all-contributors 491 | [all-contributors-badge]: https://img.shields.io/github/all-contributors/kentcdodds/match-sorter?color=orange&style=flat-square 492 | [bugs]: https://github.com/kentcdodds/match-sorter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Acreated-desc+label%3Abug 493 | [requests]: https://github.com/kentcdodds/match-sorter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement 494 | [good-first-issue]: https://github.com/kentcdodds/match-sorter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement+label%3A%22good+first+issue%22 495 | 496 | [genie]: https://github.com/kentcdodds/genie 497 | 498 | --------------------------------------------------------------------------------