11import { localized , msg } from "@lit/localize" ;
2- import type { SlInput , SlMenuItem } from "@shoelace-style/shoelace" ;
2+ import type {
3+ SlClearEvent ,
4+ SlInput ,
5+ SlMenuItem ,
6+ } from "@shoelace-style/shoelace" ;
37import Fuse from "fuse.js" ;
4- import { html , LitElement , nothing , type PropertyValues } from "lit" ;
8+ import { html , nothing , type PropertyValues } from "lit" ;
59import { customElement , property , query , state } from "lit/decorators.js" ;
10+ import { ifDefined } from "lit/directives/if-defined.js" ;
611import { when } from "lit/directives/when.js" ;
712import debounce from "lodash/fp/debounce" ;
813
14+ import { TailwindElement } from "@/classes/TailwindElement" ;
15+ import { defaultFuseOptions } from "@/context/search-org/connectFuse" ;
16+ import type { BtrixSelectEvent } from "@/events/btrix-select" ;
917import { type UnderlyingFunction } from "@/types/utils" ;
18+ import { isNotEqual } from "@/utils/is-not-equal" ;
1019
11- type SelectEventDetail < T > = {
20+ export type BtrixSearchComboboxSelectEvent = BtrixSelectEvent < {
1221 key : string | null ;
13- value ?: T ;
14- } ;
15- export type SelectEvent < T > = CustomEvent < SelectEventDetail < T > > ;
22+ value : string ;
23+ } > ;
1624
1725const MIN_SEARCH_LENGTH = 2 ;
18- const MAX_SEARCH_RESULTS = 10 ;
26+ const MAX_SEARCH_RESULTS = 5 ;
1927
2028/**
2129 * Fuzzy search through list of options
2230 *
23- * @event btrix-select
24- * @event btrix-clear
31+ * @fires btrix-select
32+ * @fires btrix-clear
2533 */
2634@customElement ( "btrix-search-combobox" )
2735@localized ( )
28- export class SearchCombobox < T > extends LitElement {
36+ export class SearchCombobox < T > extends TailwindElement {
2937 @property ( { type : Array } )
3038 searchOptions : T [ ] = [ ] ;
3139
32- @property ( { type : Array } )
40+ @property ( { type : Array , hasChanged : isNotEqual } )
3341 searchKeys : string [ ] = [ ] ;
3442
35- @property ( { type : Object } )
43+ @property ( { type : Object , hasChanged : isNotEqual } )
3644 keyLabels ?: { [ key : string ] : string } ;
3745
3846 @property ( { type : String } )
@@ -44,6 +52,30 @@ export class SearchCombobox<T> extends LitElement {
4452 @property ( { type : String } )
4553 searchByValue = "" ;
4654
55+ @property ( { type : String } )
56+ label ?: string ;
57+
58+ @property ( { type : String } )
59+ name ?: string ;
60+
61+ @property ( { type : Number } )
62+ maxlength ?: number ;
63+
64+ @property ( { type : Boolean } )
65+ required ?: boolean ;
66+
67+ @property ( { type : Boolean } )
68+ disabled ?: boolean ;
69+
70+ @property ( { type : String } )
71+ size ?: SlInput [ "size" ] ;
72+
73+ @property ( { type : Boolean } )
74+ disableSearch = false ;
75+
76+ @property ( { type : Boolean } )
77+ createNew = false ;
78+
4779 private get hasSearchStr ( ) {
4880 return this . searchByValue . length >= MIN_SEARCH_LENGTH ;
4981 }
@@ -54,10 +86,9 @@ export class SearchCombobox<T> extends LitElement {
5486 @query ( "sl-input" )
5587 private readonly input ! : SlInput ;
5688
57- private fuse = new Fuse < T > ( [ ] , {
58- keys : [ ] ,
59- threshold : 0.2 , // stricter; default is 0.6
60- includeMatches : true ,
89+ protected fuse = new Fuse < T > ( this . searchOptions , {
90+ ...defaultFuseOptions ,
91+ keys : this . searchKeys ,
6192 } ) ;
6293
6394 disconnectedCallback ( ) : void {
@@ -72,7 +103,7 @@ export class SearchCombobox<T> extends LitElement {
72103 }
73104 if ( changedProperties . has ( "searchKeys" ) ) {
74105 this . onSearchInput . cancel ( ) ;
75- this . fuse = new Fuse < T > ( [ ] , {
106+ this . fuse = new Fuse < T > ( this . searchOptions , {
76107 ...(
77108 this . fuse as unknown as {
78109 options : ConstructorParameters < typeof Fuse > [ 1 ] ;
@@ -106,21 +137,27 @@ export class SearchCombobox<T> extends LitElement {
106137 this . searchByValue = value ;
107138 await this . updateComplete ;
108139 this . dispatchEvent (
109- new CustomEvent < SelectEventDetail < T > > ( "btrix-select" , {
110- detail : {
111- key : key ?? null ,
112- value : value as T ,
140+ new CustomEvent < BtrixSearchComboboxSelectEvent [ "detail" ] > (
141+ "btrix-select" ,
142+ {
143+ detail : {
144+ item : { key : key ?? null , value : value } ,
145+ } ,
113146 } ,
114- } ) ,
147+ ) ,
115148 ) ;
116149 } }
117150 >
118151 < sl-input
119- size ="small "
120152 placeholder =${ this . placeholder }
153+ label =${ ifDefined ( this . label ) }
154+ size=${ ifDefined ( this . size ) }
155+ maxlength=${ ifDefined ( this . maxlength ) }
156+ ?disabled=${ this . disabled }
121157 clearable
122158 value=${ this . searchByValue }
123- @sl-clear =${ ( ) => {
159+ @sl-clear=${ ( e : SlClearEvent ) => {
160+ e . stopPropagation ( ) ;
124161 this . searchResultsOpen = false ;
125162 this . onSearchInput . cancel ( ) ;
126163 this . dispatchEvent ( new CustomEvent ( "btrix-clear" ) ) ;
@@ -139,7 +176,10 @@ export class SearchCombobox<T> extends LitElement {
139176 style ="margin-left: var(--sl-spacing-3x-small) "
140177 > ${ this . keyLabels ! [ this . selectedKey ! ] } </ sl-tag
141178 > ` ,
142- ( ) => html `< sl-icon name ="search " slot ="prefix "> </ sl-icon > ` ,
179+ ( ) =>
180+ this . disableSearch
181+ ? nothing
182+ : html `< sl-icon name ="search " slot ="prefix "> </ sl-icon > ` ,
143183 ) }
144184 </ sl-input >
145185 ${ this . renderSearchResults ( ) }
@@ -160,38 +200,65 @@ export class SearchCombobox<T> extends LitElement {
160200 limit : MAX_SEARCH_RESULTS ,
161201 } ) ;
162202
163- if ( ! searchResults . length ) {
164- return html `
165- < sl-menu-item slot ="menu-item " disabled
166- > ${ msg ( "No matches found." ) } </ sl-menu-item
167- >
168- ` ;
169- }
203+ const match = ( { key, value } : Fuse . FuseResultMatch ) => {
204+ if ( ! ! key && ! ! value ) {
205+ const keyLabel = this . keyLabels ?. [ key ] ;
206+ return html `
207+ < sl-menu-item slot ="menu-item " data-key =${ key } value =${ value } >
208+ ${ keyLabel
209+ ? html `< sl-tag slot ="prefix " size ="small " pill
210+ > ${ keyLabel } </ sl-tag
211+ > `
212+ : nothing }
213+ ${ value }
214+ </ sl-menu-item >
215+ ` ;
216+ }
217+ return nothing ;
218+ } ;
219+
220+ const newName = this . searchByValue . trim ( ) ;
221+ // Hide "Add" if there's a result that matches the entire string (case insensitive)
222+ const showCreateNew =
223+ this . createNew &&
224+ ! searchResults . some ( ( res ) =>
225+ res . matches ?. some (
226+ ( { value } ) =>
227+ value && value . toLocaleLowerCase ( ) === newName . toLocaleLowerCase ( ) ,
228+ ) ,
229+ ) ;
170230
171231 return html `
172- ${ searchResults . map ( ( { matches } ) =>
173- matches ?. map ( ( { key, value } ) => {
174- if ( ! ! key && ! ! value ) {
175- const keyLabel = this . keyLabels ?. [ key ] ;
176- return html `
177- < sl-menu-item slot ="menu-item " data-key =${ key } value =${ value } >
178- ${ keyLabel
179- ? html `< sl-tag slot ="prefix " size ="small " pill
180- > ${ keyLabel } </ sl-tag
181- > `
182- : nothing }
183- ${ value }
184- </ sl-menu-item >
185- ` ;
186- }
187- return nothing ;
188- } ) ,
232+ ${ when (
233+ searchResults . length ,
234+ ( ) => html `
235+ ${ searchResults . map ( ( { matches } ) => matches ?. map ( match ) ) }
236+ ${ showCreateNew
237+ ? html `< sl-divider slot ="menu-item "> </ sl-divider > `
238+ : nothing }
239+ ` ,
240+ ( ) =>
241+ showCreateNew
242+ ? nothing
243+ : html `
244+ < sl-menu-item slot ="menu-item " disabled
245+ > ${ msg ( "No matches found." ) } </ sl-menu-item
246+ >
247+ ` ,
189248 ) }
249+ ${ when ( showCreateNew , ( ) => {
250+ return html `
251+ < sl-menu-item slot ="menu-item " value =${ newName } >
252+ < span class ="text-neutral-500 "> ${ msg ( "Create" ) } “</ span
253+ > ${ newName } < span class ="text-neutral-500 "> ”</ span >
254+ </ sl-menu-item >
255+ ` ;
256+ } ) }
190257 ` ;
191258 }
192259
193260 private readonly onSearchInput = debounce ( 150 ) ( ( ) => {
194- this . searchByValue = this . input . value . trim ( ) ;
261+ this . searchByValue = this . input . value ;
195262
196263 if ( ! this . searchResultsOpen && this . hasSearchStr ) {
197264 this . searchResultsOpen = true ;
0 commit comments