diff --git a/cmdk/package.json b/cmdk/package.json index 8fcec53..f4b5a87 100644 --- a/cmdk/package.json +++ b/cmdk/package.json @@ -19,8 +19,7 @@ "react-dom": "^18.0.0" }, "dependencies": { - "@radix-ui/react-dialog": "1.0.0", - "command-score": "0.1.2" + "@radix-ui/react-dialog": "1.0.0" }, "devDependencies": { "@types/react": "18.0.15" diff --git a/cmdk/src/command-score.ts b/cmdk/src/command-score.ts new file mode 100644 index 0000000..f79cccc --- /dev/null +++ b/cmdk/src/command-score.ts @@ -0,0 +1,161 @@ +// The scores are arranged so that a continuous match of characters will +// result in a total score of 1. +// +// The best case, this character is a match, and either this is the start +// of the string, or the previous character was also a match. +var SCORE_CONTINUE_MATCH = 1, + // A new match at the start of a word scores better than a new match + // elsewhere as it's more likely that the user will type the starts + // of fragments. + // NOTE: We score word jumps between spaces slightly higher than slashes, brackets + // hyphens, etc. + SCORE_SPACE_WORD_JUMP = 0.9, + SCORE_NON_SPACE_WORD_JUMP = 0.8, + // Any other match isn't ideal, but we include it for completeness. + SCORE_CHARACTER_JUMP = 0.17, + // If the user transposed two letters, it should be significantly penalized. + // + // i.e. "ouch" is more likely than "curtain" when "uc" is typed. + SCORE_TRANSPOSITION = 0.1, + // The goodness of a match should decay slightly with each missing + // character. + // + // i.e. "bad" is more likely than "bard" when "bd" is typed. + // + // This will not change the order of suggestions based on SCORE_* until + // 100 characters are inserted between matches. + PENALTY_SKIPPED = 0.999, + // The goodness of an exact-case match should be higher than a + // case-insensitive match by a small amount. + // + // i.e. "HTML" is more likely than "haml" when "HM" is typed. + // + // This will not change the order of suggestions based on SCORE_* until + // 1000 characters are inserted between matches. + PENALTY_CASE_MISMATCH = 0.9999, + // Match higher for letters closer to the beginning of the word + PENALTY_DISTANCE_FROM_START = 0.9, + // If the word has more characters than the user typed, it should + // be penalised slightly. + // + // i.e. "html" is more likely than "html5" if I type "html". + // + // However, it may well be the case that there's a sensible secondary + // ordering (like alphabetical) that it makes sense to rely on when + // there are many prefix matches, so we don't make the penalty increase + // with the number of tokens. + PENALTY_NOT_COMPLETE = 0.99 + +var IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/, + COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g, + IS_SPACE_REGEXP = /[\s-]/, + COUNT_SPACE_REGEXP = /[\s-]/g + +function commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + stringIndex, + abbreviationIndex, + memoizedResults, +) { + if (abbreviationIndex === abbreviation.length) { + if (stringIndex === string.length) { + return SCORE_CONTINUE_MATCH + } + return PENALTY_NOT_COMPLETE + } + + var memoizeKey = `${stringIndex},${abbreviationIndex}` + if (memoizedResults[memoizeKey] !== undefined) { + return memoizedResults[memoizeKey] + } + + var abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex) + var index = lowerString.indexOf(abbreviationChar, stringIndex) + var highScore = 0 + + var score, transposedScore, wordBreaks, spaceBreaks + + while (index >= 0) { + score = commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 1, + memoizedResults, + ) + if (score > highScore) { + if (index === stringIndex) { + score *= SCORE_CONTINUE_MATCH + } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_NON_SPACE_WORD_JUMP + wordBreaks = string.slice(stringIndex, index - 1).match(COUNT_GAPS_REGEXP) + if (wordBreaks && stringIndex > 0) { + score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length) + } + } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { + score *= SCORE_SPACE_WORD_JUMP + spaceBreaks = string.slice(stringIndex, index - 1).match(COUNT_SPACE_REGEXP) + if (spaceBreaks && stringIndex > 0) { + score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length) + } + } else { + score *= SCORE_CHARACTER_JUMP + if (stringIndex > 0) { + score *= Math.pow(PENALTY_SKIPPED, index - stringIndex) + } + } + + if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { + score *= PENALTY_CASE_MISMATCH + } + } + + if ( + (score < SCORE_TRANSPOSITION && + lowerString.charAt(index - 1) === lowerAbbreviation.charAt(abbreviationIndex + 1)) || + (lowerAbbreviation.charAt(abbreviationIndex + 1) === lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428 + lowerString.charAt(index - 1) !== lowerAbbreviation.charAt(abbreviationIndex)) + ) { + transposedScore = commandScoreInner( + string, + abbreviation, + lowerString, + lowerAbbreviation, + index + 1, + abbreviationIndex + 2, + memoizedResults, + ) + + if (transposedScore * SCORE_TRANSPOSITION > score) { + score = transposedScore * SCORE_TRANSPOSITION + } + } + + if (score > highScore) { + highScore = score + } + + index = lowerString.indexOf(abbreviationChar, index + 1) + } + + memoizedResults[memoizeKey] = highScore + return highScore +} + +function formatInput(string) { + // convert all valid space characters to space so they match each other + return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ') +} + +export function commandScore(string: string, abbreviation: string): number { + /* NOTE: + * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() + * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. + */ + return commandScoreInner(string, abbreviation, formatInput(string), formatInput(abbreviation), 0, 0, {}) +} diff --git a/cmdk/src/index.tsx b/cmdk/src/index.tsx index a57f005..bd5c229 100644 --- a/cmdk/src/index.tsx +++ b/cmdk/src/index.tsx @@ -1,6 +1,6 @@ import * as RadixDialog from '@radix-ui/react-dialog' import * as React from 'react' -import commandScore from 'command-score' +import { commandScore } from './command-score' type Children = { children?: React.ReactNode } type DivProps = React.HTMLAttributes diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 324b651..870b215 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,10 +18,8 @@ importers: specifiers: '@radix-ui/react-dialog': 1.0.0 '@types/react': 18.0.15 - command-score: 0.1.2 dependencies: '@radix-ui/react-dialog': 1.0.0_@types+react@18.0.15 - command-score: 0.1.2 devDependencies: '@types/react': 18.0.15 @@ -1440,10 +1438,6 @@ packages: resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} dev: true - /command-score/0.1.2: - resolution: {integrity: sha512-VtDvQpIJBvBatnONUsPzXYFVKQQAhuf3XTNOAsdBxCNO/QCtUUd8LSgjn0GVarBkCad6aJCZfXgrjYbl/KRr7w==} - dev: false - /commander/4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} diff --git a/test/numeric.test.ts b/test/numeric.test.ts new file mode 100644 index 0000000..28a0a10 --- /dev/null +++ b/test/numeric.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test' + +test.describe('behavior for numeric values', async () => { + test.beforeEach(async ({ page }) => { + await page.goto('/numeric') + }) + + test('items filter correctly on numeric inputs', async ({ page }) => { + const input = page.locator(`[cmdk-input]`) + await input.type('112') + const removed = page.locator(`[cmdk-item][data-value="removed"]`) + const remains = page.locator(`[cmdk-item][data-value="foo.bar112.value"]`) + await expect(removed).toHaveCount(0) + await expect(remains).toHaveCount(1) + }) + + test('items filter correctly on non-numeric inputs', async ({ page }) => { + const input = page.locator(`[cmdk-input]`) + await input.type('bar') + const removed = page.locator(`[cmdk-item][data-value="removed"]`) + const remains = page.locator(`[cmdk-item][data-value="foo.bar112.value"]`) + await expect(removed).toHaveCount(0) + await expect(remains).toHaveCount(1) + }) +}) diff --git a/test/pages/numeric.tsx b/test/pages/numeric.tsx new file mode 100644 index 0000000..e05ad7e --- /dev/null +++ b/test/pages/numeric.tsx @@ -0,0 +1,22 @@ +import { Command } from 'cmdk' + +const Page = () => { + return ( +
+ + + + No results. + + To be removed + + + Not to be removed + + + +
+ ) +} + +export default Page