Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4c3fc17
make root div focusable so that navigation keys work even when search…
glocore Nov 2, 2022
64bef04
Use `offsetHeight` to calculate list height (#121)
Apr 17, 2023
0d74730
Add `data-disabled` to `Item` and recommend using data attributes (#122)
Apr 18, 2023
bbcc8cf
feat: add default value prop (#123)
revogabe May 3, 2023
53da94a
chore: use forceMount from group as fallback (#119)
joaom00 May 3, 2023
aeeaf32
Fix: `selected` and `disabled` state (#84)
prestonbourne May 3, 2023
0f5bf58
Revert "Fix: `selected` and `disabled` state" (#131)
pacocoursey May 3, 2023
45be6de
Bring in local dependency of command-score (#130)
pacocoursey May 3, 2023
563c2bd
docs: add forceMount prop (#134)
May 10, 2023
db1e29a
docs: update dialog example (#136)
May 15, 2023
a5e4004
Add links to title badges (#151)
jrysana Jul 5, 2023
986e06b
docs: tiny fix (#153)
0xcadams Jul 14, 2023
ba2e200
fix: added div props type to loading (#154)
0xcadams Jul 14, 2023
dc0d0e5
Add option for disabling vim bindings (#168)
bkrausz Aug 14, 2023
5c24283
fix: IME conversion bug (#166)
hajimism Aug 14, 2023
05199cb
Fix bug with backspacing (#176)
WITS Sep 1, 2023
eb77876
update playwright and pnpm version
pacocoursey Jan 30, 2024
1f26a99
fix React prop warning with forceMount
pacocoursey Jan 30, 2024
dbf9e33
add section to README about testing
pacocoursey Jan 30, 2024
ed8a847
upgrade tsup
pacocoursey Jan 30, 2024
5916743
use pnpm in all bash examples
pacocoursey Jan 30, 2024
36cb114
nit
pacocoursey Jan 30, 2024
8a3bf1b
v0.2.1
pacocoursey Jan 30, 2024
c63d6ec
fix: add exports field for support esm (#141)
SoYoung210 Jan 30, 2024
e97839f
Disable select item with mouse (#116)
joaom00 Jan 30, 2024
b768f2b
Fix forceMount too many rerenders (#143)
joaom00 Jan 30, 2024
9b97580
fix: only scroll into view on first render and via keyboard (#135)
joaom00 Jan 30, 2024
58110a0
deps: react-dialog version (#194)
wmcheung Jan 30, 2024
f4049e6
fix: items sort by not working (#182)
pengx17 Jan 30, 2024
eb404c0
feat: add keywords prop to the item component (#158)
itaikeren Jan 30, 2024
071ee2d
feat: add asChild prop (#138)
joaom00 Jan 30, 2024
c7fd231
lint format
pacocoursey Jan 30, 2024
1724300
Fix suggestions list and loading progressbar labels (#204)
afercia Jan 30, 2024
1232b1c
remove outline: none
pacocoursey Jan 30, 2024
9688cec
Merge branch 'main' into navigation-keys-fix
pacocoursey Jan 30, 2024
144c69c
add outline: none to website
pacocoursey Jan 30, 2024
26df6ac
format
pacocoursey Jan 30, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 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 @@ -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 normalized as lowercase and 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 normalized as lowercase and 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
```
13 changes: 10 additions & 3 deletions cmdk/package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
{
"name": "cmdk",
"version": "0.2.0",
"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,8 +26,8 @@
"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"
Expand Down
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