11<template >
22 <div ref =" containerRef" class =" relative inline-block w-full" >
3+ <!-- Search mode: input trigger -->
4+ <StyledInput
5+ v-if =" searchMode"
6+ ref =" searchModeTriggerRef"
7+ v-model =" searchQuery"
8+ :icon =" SearchIcon"
9+ type =" text"
10+ :placeholder =" searchPlaceholder || placeholder"
11+ :disabled =" disabled"
12+ wrapper-class =" w-full"
13+ :input-class ="
14+ isOpen
15+ ? shouldRoundBottomCorners
16+ ? '!rounded-b-none'
17+ : '!rounded-t-none'
18+ : ''
19+ "
20+ class =" z-[9999] relative"
21+ @input =" handleSearchModeInput"
22+ @keydown =" handleSearchKeydown"
23+ @focus =" handleSearchModeFocus"
24+ />
25+
26+ <!-- Standard mode: button trigger -->
327 <span
28+ v-else
429 ref =" triggerRef"
530 role =" button"
631 tabindex =" 0"
5681 @mousedown.stop
5782 @keydown =" handleDropdownKeydown"
5883 >
59- <div v-if =" searchable " class =" p-4" >
84+ <div v-if =" isSearchable && !searchMode " class =" p-4" >
6085 <StyledInput
6186 ref =" searchInputRef"
6287 v-model =" searchQuery"
7095 />
7196 </div >
7297
73- <div v-if =" searchable && filteredOptions.length > 0" class =" h-px bg-surface-5" ></div >
98+ <div
99+ v-if =" searchable && !searchMode && filteredOptions.length > 0"
100+ class =" h-px bg-surface-5"
101+ ></div >
74102
75103 <div
76104 v-if =" filteredOptions.length > 0"
114142 <div v-else-if =" searchQuery" class =" p-4 mb-2 text-center text-sm text-secondary" >
115143 {{ noOptionsMessage }}
116144 </div >
145+
146+ <slot name =" dropdown-footer" ></slot >
117147 </div >
118148 </Teleport >
119149 </div >
@@ -178,6 +208,7 @@ const props = withDefaults(
178208 forceDirection? : ' up' | ' down'
179209 noOptionsMessage? : string
180210 disableSearchFilter? : boolean
211+ searchMode? : boolean
181212 }>(),
182213 {
183214 placeholder: ' Select an option' ,
@@ -208,12 +239,20 @@ const searchQuery = ref('')
208239const focusedIndex = ref (- 1 )
209240const containerRef = ref <HTMLElement >()
210241const triggerRef = ref <HTMLElement >()
242+ const searchModeTriggerRef = ref <InstanceType <typeof StyledInput >>()
211243const dropdownRef = ref <HTMLElement >()
212244const searchInputRef = ref <HTMLInputElement >()
213245const optionsContainerRef = ref <HTMLElement >()
214246const optionRefs = ref <(HTMLElement | null )[]>([])
215247const rafId = ref <number | null >(null )
216248
249+ const effectiveTriggerEl = computed (() => {
250+ if (props .searchMode && searchModeTriggerRef .value ) {
251+ return (searchModeTriggerRef .value as unknown as { $el: HTMLElement }).$el as HTMLElement
252+ }
253+ return triggerRef .value
254+ })
255+
217256const dropdownStyle = ref ({
218257 top: ' 0px' ,
219258 left: ' 0px' ,
@@ -253,8 +292,10 @@ const optionsWithKeys = computed(() => {
253292 }))
254293})
255294
295+ const isSearchable = computed (() => props .searchable || props .searchMode )
296+
256297const filteredOptions = computed (() => {
257- if (! searchQuery .value || ! props . searchable || props .disableSearchFilter ) {
298+ if (! searchQuery .value || ! isSearchable . value || props .disableSearchFilter ) {
258299 return optionsWithKeys .value
259300 }
260301
@@ -342,11 +383,11 @@ function calculateHorizontalPosition(
342383}
343384
344385async function updateDropdownPosition() {
345- if (! triggerRef .value || ! dropdownRef .value ) return
386+ if (! effectiveTriggerEl .value || ! dropdownRef .value ) return
346387
347388 await nextTick ()
348389
349- const triggerRect = triggerRef .value .getBoundingClientRect ()
390+ const triggerRect = effectiveTriggerEl .value .getBoundingClientRect ()
350391 const dropdownRect = dropdownRef .value .getBoundingClientRect ()
351392 const viewportHeight = window .innerHeight
352393 const viewportWidth = window .innerWidth
@@ -368,15 +409,19 @@ async function openDropdown() {
368409 if (props .disabled || isOpen .value ) return
369410
370411 isOpen .value = true
371- searchQuery .value = ' '
412+ if (! props .searchMode ) {
413+ searchQuery .value = ' '
414+ }
372415
373416 emit (' open' )
374417
375418 await nextTick ()
376419 await updateDropdownPosition ()
377420
378421 setInitialFocus ()
379- focusSearchInput ()
422+ if (! props .searchMode ) {
423+ focusSearchInput ()
424+ }
380425 startPositionTracking ()
381426}
382427
@@ -385,13 +430,17 @@ function closeDropdown() {
385430
386431 stopPositionTracking ()
387432 isOpen .value = false
388- searchQuery .value = ' '
433+ if (! props .searchMode ) {
434+ searchQuery .value = ' '
435+ }
389436 focusedIndex .value = - 1
390437 emit (' close' )
391438
392- nextTick (() => {
393- triggerRef .value ?.focus ()
394- })
439+ if (! props .searchMode ) {
440+ nextTick (() => {
441+ triggerRef .value ?.focus ()
442+ })
443+ }
395444}
396445
397446function handleTriggerClick() {
@@ -418,6 +467,9 @@ function handleOptionClick(option: ComboboxOption<T>, index: number) {
418467 emit (' select' , option )
419468
420469 if (option .type !== ' link' ) {
470+ if (props .searchMode ) {
471+ searchQuery .value = ' '
472+ }
421473 closeDropdown ()
422474 }
423475}
@@ -442,7 +494,9 @@ function focusOption(index: number) {
442494 if (isDivider (option ) || option .disabled ) return
443495
444496 focusedIndex .value = index
445- optionRefs .value [index ]?.focus ()
497+ if (! props .searchMode ) {
498+ optionRefs .value [index ]?.focus ()
499+ }
446500 optionRefs .value [index ]?.scrollIntoView ({ block: ' nearest' })
447501}
448502
@@ -512,10 +566,43 @@ function handleSearchKeydown(event: KeyboardEvent) {
512566 closeDropdown ()
513567 } else if (event .key === ' ArrowDown' ) {
514568 event .preventDefault ()
569+ if (props .searchMode && ! isOpen .value && searchQuery .value .trim ()) {
570+ openDropdown ()
571+ }
515572 focusNextOption ()
516573 } else if (event .key === ' ArrowUp' ) {
517574 event .preventDefault ()
518575 focusPreviousOption ()
576+ } else if (event .key === ' Enter' ) {
577+ event .preventDefault ()
578+ if (focusedIndex .value >= 0 ) {
579+ const option = filteredOptions .value [focusedIndex .value ]
580+ if (option && ! isDivider (option )) {
581+ handleOptionClick (option , focusedIndex .value )
582+ }
583+ }
584+ } else if (event .key === ' Tab' && props .searchMode && isOpen .value ) {
585+ event .preventDefault ()
586+ if (event .shiftKey ) {
587+ focusPreviousOption ()
588+ } else {
589+ focusNextOption ()
590+ }
591+ }
592+ }
593+
594+ function handleSearchModeInput() {
595+ emit (' searchInput' , searchQuery .value )
596+ if (searchQuery .value .trim () && ! isOpen .value ) {
597+ openDropdown ()
598+ } else if (! searchQuery .value .trim () && isOpen .value ) {
599+ closeDropdown ()
600+ }
601+ }
602+
603+ function handleSearchModeFocus() {
604+ if (searchQuery .value .trim () && ! isOpen .value ) {
605+ openDropdown ()
519606 }
520607}
521608
@@ -545,7 +632,7 @@ onClickOutside(
545632 () => {
546633 closeDropdown ()
547634 },
548- { ignore: [triggerRef ] },
635+ { ignore: [triggerRef , containerRef ] },
549636)
550637
551638onMounted (() => {
0 commit comments