@@ -3,6 +3,7 @@ import clsx from 'clsx';
33import { Command } from 'cmdk-base' ;
44import { X } from 'lucide-react' ;
55import {
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+
89209const initialMatchedSnippet = ' ' ;
90210const 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