@@ -6,74 +6,160 @@ export interface UseSearchOptions<T> {
66 searchFields : ( item : T ) => string [ ] ;
77}
88
9+ // Scoring constants used to rank matches
10+ const SCORE = {
11+ normalizedExact : 1000 ,
12+ exact : 900 ,
13+ startsWith : 700 ,
14+ wordBoundary : 500 ,
15+ contains : 300 ,
16+ normalizedContains : 100 ,
17+ phraseExact : 2500 ,
18+ phraseNormalized : 2200 ,
19+ phraseStartsWith : 300 ,
20+ } as const ;
21+
22+ interface FieldData {
23+ lowerFields : string [ ] ;
24+ normalizedFields : string [ ] ;
25+ weights : number [ ] ;
26+ searchableText : string ;
27+ normalizedText : string ;
28+ }
29+
930export function useSearch < T > ( {
1031 items,
1132 searchQuery,
1233 searchFields,
1334} : UseSearchOptions < T > ) {
1435 return useMemo ( ( ) => {
15- if ( ! searchQuery . trim ( ) ) {
16- return items ;
17- }
36+ const trimmedQuery = searchQuery . trim ( ) ;
37+ if ( ! trimmedQuery ) return items ;
1838
19- const query = searchQuery . toLowerCase ( ) . trim ( ) ;
39+ const query = trimmedQuery . toLowerCase ( ) ;
2040
2141 // Split by spaces and dashes to create search terms
2242 const searchTerms = query
2343 . split ( / [ \s - ] + / )
2444 . filter ( ( term ) => term . length > 0 )
2545 . map ( ( term ) => term . toLowerCase ( ) ) ;
2646
27- if ( searchTerms . length === 0 ) {
28- return items ;
29- }
47+ if ( searchTerms . length === 0 ) return items ;
3048
31- return items . filter ( ( item ) => {
32- // Get searchable fields for this item
33- const searchableFields = searchFields ( item ) . map ( ( field ) =>
34- field . toLowerCase ( )
35- ) ;
49+ // Helpers
50+ const escapeRegex = ( value : string ) =>
51+ value . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
52+ const normalizeCompact = ( value : string ) => value . replace ( / [ \s - _ ] / g, "" ) ;
3653
37- const searchableText = searchableFields . join ( " " ) ;
54+ const buildFieldData = ( fields : string [ ] ) : FieldData => {
55+ const lowerFields = fields . map ( ( f ) => f . toLowerCase ( ) ) ;
56+ const normalizedFields = lowerFields . map ( ( f ) => normalizeCompact ( f ) ) ;
57+ const weights = fields . map ( ( _ , index ) => fields . length - index ) ;
58+ return {
59+ lowerFields,
60+ normalizedFields,
61+ weights,
62+ searchableText : lowerFields . join ( " " ) ,
63+ normalizedText : normalizedFields . join ( " " ) ,
64+ } ;
65+ } ;
3866
39- // Create normalized versions (without spaces, dashes, underscores) for better matching
40- const normalizedFields = searchableFields . map ( ( field ) =>
41- field . replace ( / [ \s - _ ] / g, "" ) . toLowerCase ( )
42- ) ;
43- const normalizedSearchableText = normalizedFields . join ( " " ) ;
67+ const normalizedQuery = normalizeCompact ( query ) ;
4468
45- // Check if all search terms are found (AND logic for better precision)
69+ const matchesAllTerms = ( data : FieldData ) : boolean => {
4670 return searchTerms . every ( ( term ) => {
47- const normalizedTerm = term . replace ( / [ \s - _ ] / g, "" ) . toLowerCase ( ) ;
48-
49- // Check direct substring match in any field
50- if ( searchableText . includes ( term ) ) {
51- return true ;
52- }
71+ const normalizedTerm = normalizeCompact ( term ) ;
72+ if ( data . searchableText . includes ( term ) ) return true ;
73+ if ( data . normalizedText . includes ( normalizedTerm ) ) return true ;
5374
54- // Check normalized match (handles "tostring" matching "To String")
55- if ( normalizedSearchableText . includes ( normalizedTerm ) ) {
75+ const wordBoundaryRegex = new RegExp ( `\\b ${ escapeRegex ( term ) } ` , "i" ) ;
76+ if ( data . lowerFields . some ( ( field ) => wordBoundaryRegex . test ( field ) ) ) {
5677 return true ;
5778 }
5879
59- // Check if term matches word boundaries (more precise matching)
60- const wordBoundaryRegex = new RegExp (
61- `\\b${ term . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) } ` ,
80+ const normWordBoundaryRegex = new RegExp (
81+ `\\b${ escapeRegex ( normalizedTerm ) } ` ,
6282 "i"
6383 ) ;
64- if ( searchableFields . some ( ( field ) => wordBoundaryRegex . test ( field ) ) ) {
65- return true ;
84+ return data . normalizedFields . some ( ( field ) =>
85+ normWordBoundaryRegex . test ( field )
86+ ) ;
87+ } ) ;
88+ } ;
89+
90+ const computeItemScore = ( data : FieldData ) : number => {
91+ let totalScore = 0 ;
92+
93+ for ( const term of searchTerms ) {
94+ const normalizedTerm = normalizeCompact ( term ) ;
95+ for ( let i = 0 ; i < data . lowerFields . length ; i ++ ) {
96+ const field = data . lowerFields [ i ] ;
97+ const normField = data . normalizedFields [ i ] ;
98+ const weight = data . weights [ i ] ;
99+
100+ if ( normField === normalizedTerm ) {
101+ totalScore += SCORE . normalizedExact * weight ;
102+ continue ;
103+ }
104+ if ( field === term ) {
105+ totalScore += SCORE . exact * weight ;
106+ continue ;
107+ }
108+ if ( field . startsWith ( term ) ) {
109+ totalScore += SCORE . startsWith * weight ;
110+ continue ;
111+ }
112+ const wordBoundaryRegex = new RegExp ( `\\b${ escapeRegex ( term ) } ` , "i" ) ;
113+ if ( wordBoundaryRegex . test ( field ) ) {
114+ totalScore += SCORE . wordBoundary * weight ;
115+ continue ;
116+ }
117+ if ( field . includes ( term ) ) {
118+ totalScore += SCORE . contains * weight ;
119+ continue ;
120+ }
121+ if ( normField . includes ( normalizedTerm ) ) {
122+ totalScore += SCORE . normalizedContains * weight ;
123+ continue ;
124+ }
66125 }
126+ }
67127
68- // Check normalized word boundaries for individual fields
69- const normalizedWordBoundaryRegex = new RegExp (
70- `\\b${ normalizedTerm . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) } ` ,
71- "i"
72- ) ;
73- return normalizedFields . some ( ( field ) =>
74- normalizedWordBoundaryRegex . test ( field )
75- ) ;
128+ for ( let i = 0 ; i < data . lowerFields . length ; i ++ ) {
129+ const field = data . lowerFields [ i ] ;
130+ const normField = data . normalizedFields [ i ] ;
131+ const weight = data . weights [ i ] ;
132+
133+ if ( field . includes ( query ) ) {
134+ totalScore += SCORE . phraseExact * weight ;
135+ } else if ( normField . includes ( normalizedQuery ) ) {
136+ totalScore += SCORE . phraseNormalized * weight ;
137+ }
138+
139+ if ( field . startsWith ( query ) ) {
140+ totalScore += SCORE . phraseStartsWith * weight ;
141+ }
142+ }
143+
144+ return totalScore ;
145+ } ;
146+
147+ // First, determine which items match (AND across terms), then rank them
148+ const scoredMatches = items
149+ . map ( ( item , index ) => {
150+ const fields = searchFields ( item ) ;
151+ const fieldData = buildFieldData ( fields ) ;
152+ const score = matchesAllTerms ( fieldData )
153+ ? computeItemScore ( fieldData )
154+ : 0 ;
155+ return { item, score, index } ;
156+ } )
157+ . filter ( ( { score } ) => score > 0 )
158+ . sort ( ( a , b ) => {
159+ if ( b . score !== a . score ) return b . score - a . score ;
160+ return a . index - b . index ;
76161 } ) ;
77- } ) ;
162+
163+ return scoredMatches . map ( ( { item } ) => item ) ;
78164 } , [ items , searchQuery , searchFields ] ) ;
79165}
0 commit comments