Skip to content

Commit

Permalink
merge master
Browse files Browse the repository at this point in the history
  • Loading branch information
pacocoursey committed Jan 30, 2024
2 parents 470e167 + a311d59 commit 8194ceb
Show file tree
Hide file tree
Showing 32 changed files with 2,393 additions and 1,367 deletions.
8 changes: 5 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,18 @@ jobs:

steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2 # respects packageManager in package.json
- uses: actions/setup-node@v3
- run: npm install pnpm -g
with:
cache: 'pnpm'
- run: pnpm install
- run: pnpm build
- run: pnpm test:format
- run: pnpm playwright install --with-deps
- run: pnpm test
- run: pnpm test || exit 1
- name: Upload test results
if: always()
uses: actions/upload-artifact@v2
with:
name: playwright-report
path: playwright-report
path: playwright-report.json
4 changes: 4 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

pnpm lint-staged
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.next
dist
pnpm-lock.yaml
.pnpm-store
.vercel
71 changes: 63 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
<img src="./website/public/og.png" />
</p>

# ⌘K ![cmdk minzip package size](https://img.shields.io/bundlephobia/minzip/cmdk) ![cmdk package version](https://img.shields.io/npm/v/cmdk.svg?colorB=green)
# ⌘K [![cmdk minzip package size](https://img.shields.io/bundlephobia/minzip/cmdk)](https://www.npmjs.com/package/cmdk?activeTab=code) [![cmdk package version](https://img.shields.io/npm/v/cmdk.svg?colorB=green)](https://www.npmjs.com/package/cmdk)

⌘K is a command menu React component that can also be used as an accessible combobox. You render items, it filters and sorts them automatically. ⌘K supports a fully composable API <sup>[How?](/ARCHITECTURE.md)</sup>, so you can wrap items in other components or even as static JSX.
⌘K is a command menu React component that can also be used as an accessible combobox. You render items, it filters and sorts them automatically. ⌘K supports a fully composable API <sup><sup>[How?](/ARCHITECTURE.md)</sup></sup>, so you can wrap items in other components or even as static JSX.

Demo and examples: [cmdk.paco.me](https://cmdk.paco.me)

## Install

```bash
npm install cmdk
pnpm install cmdk
```

## Use
Expand Down Expand Up @@ -51,7 +51,8 @@ const CommandMenu = () => {
// Toggle the menu when ⌘K is pressed
React.useEffect(() => {
const down = (e) => {
if (e.key === 'k' && e.metaKey) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((open) => !open)
}
}
Expand Down Expand Up @@ -90,7 +91,7 @@ Render this to show the command menu inline, or use [Dialog](#dialog-cmdk-dialog

> **Note**
>
> Values are always converted to lowercase and trimmed. Use `apple`, not `Apple`.
> Values are always trimmed. Use `apple`, not `Apple`.
```tsx
const [value, setValue] = React.useState('apple')
Expand All @@ -106,7 +107,7 @@ return (
)
```

You can provide a custom `filter` function that is called to rank each item. Both strings are normalized as lowercase and trimmed.
You can provide a custom `filter` function that is called to rank each item. Both strings are trimmed.

```tsx
<Command
Expand All @@ -117,6 +118,18 @@ You can provide a custom `filter` function that is called to rank each item. Bot
/>
```

A third argument, `keywords`, can also be provided to the filter function. Keywords act as aliases for the item value, and can also affect the rank of the item. Keywords are trimmed.

```tsx
<Command
filter={(value, search, keywords) => {
const extendValue = value + ' ' + keywords.join(' ')
if (extendValue.includes(search)) return 1
return 0
}}
/>
```

Or disable filtering and sorting entirely:

```tsx
Expand All @@ -135,7 +148,9 @@ Or disable filtering and sorting entirely:

You can make the arrow keys wrap around the list (when you reach the end, it goes back to the first item) by setting the `loop` prop:

```tsx
<Command loop />
```

### Dialog `[cmdk-dialog]` `[cmdk-overlay]`

Expand Down Expand Up @@ -196,7 +211,7 @@ To scroll item into view earlier near the edges of the viewport, use scroll-padd
}
```

### Item `[cmdk-item]` `[aria-disabled?]` `[aria-selected?]`
### Item `[cmdk-item]` `[data-disabled?]` `[data-selected?]`

Item that becomes active on pointer enter. You should provide a unique `value` for each item, but it will be automatically inferred from the `.textContent`.

Expand All @@ -209,6 +224,23 @@ Item that becomes active on pointer enter. You should provide a unique `value` f
</Command.Item>
```

You can also provide a `keywords` prop to help with filtering. Keywords are trimmed.

```tsx
<Command.Item keywords={['fruit', 'apple']}>Apple</Command.Item>
```

```tsx
<Command.Item
onSelect={(value) => console.log('Selected', value)}
// Value is implicity "apple" because of the provided text content
>
Apple
</Command.Item>
```

You can force an item to always render, regardless of filtering, by passing the `forceMount` prop.

### Group `[cmdk-group]` `[hidden?]`

Groups items together with the given `heading` (`[cmdk-group-heading]`).
Expand All @@ -221,6 +253,8 @@ Groups items together with the given `heading` (`[cmdk-group-heading]`).

Groups will not unmount from the DOM, rather the `hidden` attribute is applied to hide it from view. This may be relevant in your styling.

You can force a group to always render, regardless of filtering, by passing the `forceMount` prop.

### Separator `[cmdk-separator]`

Visible when the search query is empty or `alwaysRender` is true, hidden otherwise.
Expand Down Expand Up @@ -367,7 +401,7 @@ return (
We recommend using the [Radix UI popover](https://www.radix-ui.com/docs/primitives/components/popover) component. ⌘K relies on the Radix UI Dialog component, so this will reduce your bundle size a bit due to shared dependencies.

```bash
$ npm install @radix-ui/react-popover
$ pnpm install @radix-ui/react-popover
```

Render `Command` inside of the popover content:
Expand Down Expand Up @@ -426,3 +460,24 @@ You can find global stylesheets to drop in as a starting point for styling. See
Written in 2019 by Paco ([@pacocoursey](https://twitter.com/pacocoursey)) to see if a composable combobox API was possible. Used for the Vercel command menu and autocomplete by Rauno ([@raunofreiberg](https://twitter.com/raunofreiberg)) in 2020. Re-written independently in 2022 with a simpler and more performant approach. Ideas and help from Shu ([@shuding\_](https://twitter.com/shuding_)).

[use-descendants](https://github.com/pacocoursey/use-descendants) was extracted from the 2019 version.

## Testing

First, install dependencies and Playwright browsers:

```bash
pnpm install
pnpm playwright install
```

Then ensure you've built the library:

```bash
pnpm build
```

Then run the tests using your local build against real browser engines:

```bash
pnpm test
```
16 changes: 12 additions & 4 deletions cmdk/package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
{
"name": "cmdk",
"version": "0.1.20",
"version": "0.2.1",
"license": "MIT",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"prepublishOnly": "cp ../README.md . && pnpm build",
"postpublish": "rm README.md",
Expand All @@ -19,10 +26,11 @@
"react-dom": "^18.0.0"
},
"dependencies": {
"@radix-ui/react-dialog": "1.0.0",
"command-score": "0.1.2"
"@radix-ui/react-dialog": "1.0.5",
"@radix-ui/react-primitive": "1.0.3"
},
"devDependencies": {
"@types/react": "18.0.15"
}
},
"sideEffects": false
}
162 changes: 162 additions & 0 deletions cmdk/src/command-score.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// 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, aliases: 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.
*/
string = aliases && aliases.length > 0 ? `${string + ' ' + aliases.join(' ')}` : string
return commandScoreInner(string, abbreviation, formatInput(string), formatInput(abbreviation), 0, 0, {})
}
Loading

0 comments on commit 8194ceb

Please sign in to comment.