Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add keywords prop to the item component #158

Merged
merged 1 commit into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,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 Down Expand Up @@ -212,6 +224,21 @@ 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?]`
Expand Down
3 changes: 2 additions & 1 deletion cmdk/src/command-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,11 @@ function formatInput(string) {
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ')
}

export function commandScore(string: string, abbreviation: string): number {
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, {})
}
36 changes: 21 additions & 15 deletions cmdk/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ type ItemProps = Children &
* If no value is provided, it will be inferred from `children` or the rendered `textContent`. If your `textContent` changes between renders, you _must_ provide a stable, unique `value`.
*/
value?: string
/** Optional keywords to match against when filtering. */
keywords?: string[]
/** Whether this item is forcibly rendered regardless of filtering. */
forceMount?: boolean
}
Expand Down Expand Up @@ -73,7 +75,7 @@ type CommandProps = Children &
* It should return a number between 0 and 1, with 1 being the best match and 0 being hidden entirely.
* By default, uses the `command-score` library.
*/
filter?: (value: string, search: string) => number
filter?: (value: string, search: string, keywords?: string[]) => number
/**
* Optional default item value when it is initially rendered.
*/
Expand All @@ -93,7 +95,7 @@ type CommandProps = Children &
}

type Context = {
value: (id: string, value: string) => void
value: (id: string, value: string, keywords?: string[]) => void
item: (id: string, groupId: string) => () => void
group: (id: string) => () => void
filter: () => boolean
Expand Down Expand Up @@ -128,7 +130,7 @@ const ITEM_SELECTOR = `[cmdk-item=""]`
const VALID_ITEM_SELECTOR = `${ITEM_SELECTOR}:not([aria-disabled="true"])`
const SELECT_EVENT = `cmdk-item-select`
const VALUE_ATTR = `data-value`
const defaultFilter: CommandProps['filter'] = (value, search) => commandScore(value, search)
const defaultFilter: CommandProps['filter'] = (value, search, keywords) => commandScore(value, search, keywords)

// @ts-ignore
const CommandContext = React.createContext<Context>(undefined)
Expand Down Expand Up @@ -157,7 +159,7 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
}))
const allItems = useLazyRef<Set<string>>(() => new Set()) // [...itemIds]
const allGroups = useLazyRef<Map<string, Set<string>>>(() => new Map()) // groupId → [...itemIds]
const ids = useLazyRef<Map<string, string>>(() => new Map()) // id → value
const ids = useLazyRef<Map<string, { value: string, keywords?: string[] }>>(() => new Map()) // id → { value, keywords }
const listeners = useLazyRef<Set<() => void>>(() => new Set()) // [...rerenders]
const propsRef = useAsRef(props)
const { label, children, value, onValueChange, filter, shouldFilter, ...etc } = props
Expand Down Expand Up @@ -220,11 +222,11 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded

const context: Context = React.useMemo(
() => ({
// Keep id → value mapping up-to-date
value: (id, value) => {
if (value !== ids.current.get(id)) {
ids.current.set(id, value)
state.current.filtered.items.set(id, score(value))
// Keep id → {value, keywords} mapping up-to-date
value: (id, value, keywords) => {
if (value !== ids.current.get(id)?.value) {
ids.current.set(id, { value, keywords })
state.current.filtered.items.set(id, score(value, keywords))
schedule(2, () => {
sort()
store.emit()
Expand Down Expand Up @@ -299,9 +301,9 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded
[],
)

function score(value: string) {
function score(value: string, keywords?: string[]) {
const filter = propsRef.current?.filter ?? defaultFilter
return value ? filter(value, state.current.search) : 0
return value ? filter(value, state.current.search, keywords) : 0
}

/** Sorts items by score, and groups by highest item score. */
Expand Down Expand Up @@ -386,8 +388,9 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>((props, forwarded

// Check which items should be included
for (const id of allItems.current) {
const value = ids.current.get(id)
const rank = score(value)
const value = ids.current.get(id)?.value ?? ''
const keywords = ids.current.get(id)?.keywords ?? []
const rank = score(value, keywords)
state.current.filtered.items.set(id, rank)
if (rank > 0) itemCount++
}
Expand Down Expand Up @@ -598,7 +601,7 @@ const Item = React.forwardRef<HTMLDivElement, ItemProps>((props, forwardedRef) =
return context.item(id, groupContext?.id)
}, [])

const value = useValue(id, ref, [props.value, props.children, ref])
const value = useValue(id, ref, [props.value, props.children, ref], props.keywords)

const store = useStore()
const selected = useCmdk((state) => state.value && state.value === value.current)
Expand Down Expand Up @@ -951,6 +954,7 @@ function useValue(
id: string,
ref: React.RefObject<HTMLElement>,
deps: (string | React.ReactNode | React.RefObject<HTMLElement>)[],
aliases: string[] = []
) {
const valueRef = React.useRef<string>()
const context = useCommand()
Expand All @@ -968,7 +972,9 @@ function useValue(
}
})()

context.value(id, value)
const keywords = (() => aliases.map((alias) => alias.trim().toLowerCase()))()

context.value(id, value, keywords)
ref.current?.setAttribute(VALUE_ATTR, value)
valueRef.current = value
})
Expand Down
9 changes: 9 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ test.describe('basic behavior', async () => {
await expect(remains).toHaveCount(1)
})

test('items filter when searching by keywords', async ({ page }) => {
const input = page.locator(`[cmdk-input]`)
await input.type('key')
const removed = page.locator(`[cmdk-item][data-value="xxx"]`)
const remains = page.locator(`[cmdk-item][data-value="item"]`)
await expect(removed).toHaveCount(0)
await expect(remains).toHaveCount(1)
})

test('empty component renders when there are no results', async ({ page }) => {
const input = page.locator('[cmdk-input]')
await input.type('z')
Expand Down
2 changes: 1 addition & 1 deletion test/pages/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const Page = () => {
<Command.Input placeholder="Search…" className="input" />
<Command.List className="list">
<Command.Empty className="empty">No results.</Command.Empty>
<Command.Item onSelect={() => console.log('Item selected')} className="item">
<Command.Item keywords={["key"]} onSelect={() => console.log('Item selected')} className="item">
Item
</Command.Item>
<Command.Item value="xxx" className="item">
Expand Down
22 changes: 12 additions & 10 deletions website/components/cmdk/raycast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function RaycastCMDK() {
<Command.List ref={listRef}>
<Command.Empty>No results found.</Command.Empty>
<Command.Group heading="Suggestions">
<Item value="Linear">
<Item value="Linear" keywords={["issue", "sprint"]}>
<Logo>
<LinearIcon
style={{
Expand All @@ -34,43 +34,43 @@ export function RaycastCMDK() {
</Logo>
Linear
</Item>
<Item value="Figma">
<Item value="Figma" keywords={["design", "ui", "ux"]}>
<Logo>
<FigmaIcon />
</Logo>
Figma
</Item>
<Item value="Slack">
<Item value="Slack" keywords={["chat", "team", "communication"]}>
<Logo>
<SlackIcon />
</Logo>
Slack
</Item>
<Item value="YouTube">
<Item value="YouTube" keywords={["video", "watch", "stream"]}>
<Logo>
<YouTubeIcon />
</Logo>
YouTube
</Item>
<Item value="Raycast">
<Item value="Raycast" keywords={["productivity", "tools", "apps"]}>
<Logo>
<RaycastIcon />
</Logo>
Raycast
</Item>
</Command.Group>
<Command.Group heading="Commands">
<Item isCommand value="Clipboard History">
<Item isCommand value="Clipboard History" keywords={["copy", "paste", "clipboard"]}>
<Logo>
<ClipboardIcon />
</Logo>
Clipboard History
</Item>
<Item isCommand value="Import Extension">
<Item isCommand value="Import Extension" keywords={["import", "extension"]}>
<HammerIcon />
Import Extension
</Item>
<Item isCommand value="Manage Extensions">
<Item isCommand value="Manage Extensions" keywords={["manage", "extension"]}>
<HammerIcon />
Manage Extensions
</Item>
Expand All @@ -97,14 +97,16 @@ export function RaycastCMDK() {
function Item({
children,
value,
keywords,
isCommand = false,
}: {
children: React.ReactNode
value: string
value: string,
keywords?: string[]
isCommand?: boolean
}) {
return (
<Command.Item value={value} onSelect={() => {}}>
<Command.Item value={value} keywords={keywords} onSelect={() => {}}>
{children}
<span cmdk-raycast-meta="">{isCommand ? 'Command' : 'Application'}</span>
</Command.Item>
Expand Down