Skip to content

Commit c1909bf

Browse files
Snippets: Improve search filtering (#485)
1 parent 4d611a2 commit c1909bf

File tree

3 files changed

+161
-39
lines changed

3 files changed

+161
-39
lines changed

.changeset/great-showers-relate.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'playroom': patch
3+
---
4+
5+
Snippets: Improve search filtering
6+
7+
Ensure that the snippets filtering functionality prioritises `name` over `description` and exact words matches over partial matches.
8+
To further improve the predictability, Pascal Case names are treated as separate words.

src/components/Snippets/Snippets.css.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export const snippetsContainer = style([
7878
overflow: 'auto',
7979
paddingX: 'none',
8080
margin: 'none',
81+
boxSizing: 'border-box',
8182
}),
8283
{
8384
listStyle: 'none',

src/components/Snippets/Snippets.tsx

Lines changed: 152 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import clsx from 'clsx';
33
import { Command } from 'cmdk-base';
44
import { X } from 'lucide-react';
55
import {
6+
useMemo,
67
useState,
78
useRef,
89
useContext,
@@ -86,6 +87,125 @@ const SnippetsGroup = ({
8687
<>{children}</>
8788
);
8889

90+
const SnippetItem = ({
91+
snippet,
92+
onSelect,
93+
}: {
94+
snippet: SnippetWithId;
95+
onSelect: (snippet: SnippetWithId) => void;
96+
}) => (
97+
<Command.Item
98+
key={snippet.id}
99+
value={snippet.id}
100+
onSelect={() => onSelect(snippet)}
101+
className={styles.snippet}
102+
>
103+
<Tooltip
104+
delay={true}
105+
open={
106+
/**
107+
* Only show tooltip if likely to truncate, i.e. > 50 characters.
108+
*/
109+
[snippet.name, snippet.description].join(' ').length < 50
110+
? false
111+
: undefined
112+
}
113+
side="right"
114+
sideOffset={16}
115+
label={
116+
<>
117+
{snippet.name}
118+
<br />
119+
{snippet.description}
120+
</>
121+
}
122+
trigger={
123+
<span className={styles.tooltipTrigger}>
124+
<Text truncate>
125+
<span className={styles.name}>{snippet.name}</span>{' '}
126+
<Secondary>{snippet.description}</Secondary>
127+
</Text>
128+
</span>
129+
}
130+
/>
131+
</Command.Item>
132+
);
133+
134+
const resolveScore = (
135+
item: string,
136+
search: string,
137+
modifier: number = 0
138+
): number => {
139+
const lowerItem = item.toLowerCase();
140+
141+
if (lowerItem === search) {
142+
// Is exact match
143+
return 1 + modifier;
144+
} else if (
145+
lowerItem.split(/\s+/).some((word) => word === search) ||
146+
/*
147+
* Compare to unmodified item, allowing PascalCase to be treated as separate words.
148+
* Regex also handles uppercase acronyms, e.g., 'MyHTMLComponent' => ['My', 'HTML', 'Component']
149+
*/
150+
item
151+
.split(/(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/)
152+
.some((word) => word.toLowerCase() === search)
153+
) {
154+
// Contains word that is exact match
155+
return 0.95 + modifier;
156+
} else if (lowerItem.startsWith(search)) {
157+
// Starts with match
158+
return 0.9 + modifier;
159+
} else if (lowerItem.split(/\s+/).some((word) => word.startsWith(search))) {
160+
// Contains word that starts with match
161+
return 0.85 + modifier;
162+
} else if (lowerItem.includes(search)) {
163+
// Contains search character sequence
164+
return 0.75 + modifier;
165+
}
166+
167+
return 0;
168+
};
169+
170+
const scoreSnippet = (snippet: SnippetWithId, search: string): number => {
171+
const name = snippet.name;
172+
const description = snippet.description;
173+
const searchTerm = search.toLowerCase().trim();
174+
175+
if (!searchTerm) {
176+
return 1;
177+
}
178+
179+
const scoreForName = resolveScore(name, searchTerm);
180+
if (scoreForName > 0) {
181+
return scoreForName;
182+
}
183+
184+
if (description) {
185+
const scoreForDescription = resolveScore(description, searchTerm, -0.04);
186+
if (scoreForDescription > 0) {
187+
return scoreForDescription;
188+
}
189+
}
190+
191+
// Loose subsequence: every character of search must appear in order in value
192+
let position = 0;
193+
for (const char of searchTerm) {
194+
const idx = `${name}${description ? ` ${description}` : ''}`
195+
.toLowerCase()
196+
.indexOf(char, position);
197+
if (idx === -1) {
198+
return 0;
199+
}
200+
position = idx + 1;
201+
}
202+
return 0.3;
203+
};
204+
205+
const allSnippets: SnippetWithId[] = snippetsByGroup.flatMap(
206+
([, items]) => items
207+
);
208+
89209
const initialMatchedSnippet = ' ';
90210
const Content = ({ searchRef, onSelect }: SnippetsContentProps) => {
91211
const [matchedSnippet, setMatchedSnippet] = useState(initialMatchedSnippet);
@@ -96,12 +216,23 @@ const Content = ({ searchRef, onSelect }: SnippetsContentProps) => {
96216
}, snippetPreviewDebounce);
97217

98218
const hasGroups = snippetsByGroup.length > 1;
219+
const filteredSnippets = useMemo(() => {
220+
const s = inputValue.trim();
221+
if (!s) return null;
222+
return allSnippets
223+
.map((snippet) => ({ snippet, score: scoreSnippet(snippet, s) }))
224+
.filter(({ score }) => score > 0)
225+
.sort((a, b) => b.score - a.score)
226+
.map(({ snippet }) => snippet);
227+
}, [inputValue]);
228+
const isFiltering = filteredSnippets !== null;
99229

100230
return (
101231
<div className={styles.root}>
102232
<Command
103233
label="Search snippets"
104234
loop
235+
shouldFilter={false}
105236
value={matchedSnippet}
106237
onValueChange={(v) => {
107238
debouncedPreview(snippetsById[v]);
@@ -143,7 +274,7 @@ const Content = ({ searchRef, onSelect }: SnippetsContentProps) => {
143274
<Command.List
144275
className={clsx({
145276
[styles.snippetsContainer]: true,
146-
[styles.noGroupsVerticalPadding]: !hasGroups,
277+
[styles.noGroupsVerticalPadding]: !hasGroups || isFiltering,
147278
[styles.groupHeaderScrollPadding]: hasGroups,
148279
})}
149280
label="Filtered snippets"
@@ -152,47 +283,29 @@ const Content = ({ searchRef, onSelect }: SnippetsContentProps) => {
152283
<Text tone="secondary">No snippets matching “{inputValue}</Text>
153284
</Command.Empty>
154285

155-
{snippetsByGroup.map(([group, groupSnippets]) => (
156-
<SnippetsGroup key={group} enableGroups={hasGroups} group={group}>
157-
{groupSnippets.map((snippet) => (
158-
<Command.Item
286+
{isFiltering
287+
? filteredSnippets.map((snippet) => (
288+
<SnippetItem
159289
key={snippet.id}
160-
value={snippet.id}
161-
onSelect={() => onSelect(snippet)}
162-
className={styles.snippet}
290+
snippet={snippet}
291+
onSelect={onSelect}
292+
/>
293+
))
294+
: snippetsByGroup.map(([group, groupSnippets]) => (
295+
<SnippetsGroup
296+
key={group}
297+
enableGroups={hasGroups}
298+
group={group}
163299
>
164-
<Tooltip
165-
delay={true}
166-
open={
167-
/**
168-
* Only show tooltip if likely to truncate, i.e. > 60 characters.
169-
*/
170-
[snippet.name, snippet.description].join(' ').length < 60
171-
? false
172-
: undefined
173-
}
174-
side="right"
175-
sideOffset={16}
176-
label={
177-
<>
178-
{snippet.name}
179-
<br />
180-
{snippet.description}
181-
</>
182-
}
183-
trigger={
184-
<span className={styles.tooltipTrigger}>
185-
<Text truncate>
186-
<span className={styles.name}>{snippet.name}</span>{' '}
187-
<Secondary>{snippet.description}</Secondary>
188-
</Text>
189-
</span>
190-
}
191-
/>
192-
</Command.Item>
300+
{groupSnippets.map((snippet) => (
301+
<SnippetItem
302+
key={snippet.id}
303+
snippet={snippet}
304+
onSelect={onSelect}
305+
/>
306+
))}
307+
</SnippetsGroup>
193308
))}
194-
</SnippetsGroup>
195-
))}
196309
</Command.List>
197310
</Command>
198311
</div>

0 commit comments

Comments
 (0)