1515 @focus =" handleInputFocused"
1616 @blur =" handleInputBlurred"
1717 />
18- <div v-if =" shouldDisplayOptions" ref =" optionsContainer" class =" vf-smart-select-options" >
18+ <div v-if =" shouldDisplayOptions" ref =" optionsContainer" class =" vf-smart-select-options" :class = " { grouped: isGrouped } " >
1919 <div v-if =" !isLoaded" class =" no-results" >Loading...</div >
2020 <template v-else >
21- <div
22- v-for =" option in effectiveOptions"
23- :key =" String(option.key)"
24- class =" option"
25- :class =" [highlightedOptionKey === option.key && 'highlighted', option.ref && classForOption?.(option.ref)]"
26- @mousemove =" handleOptionHover(option)"
27- @mousedown =" selectOption(option)"
28- >
29- <slot name =" option" :option =" option" >
30- <div class =" title" v-html =" option.title" />
31- <div v-if =" option.subtitle" class =" subtitle" v-html =" option.subtitle" />
32- </slot >
33- </div >
34- <div v-if =" !effectiveOptions.length && searchText" class =" no-results" >
35- <slot name =" no-results" >
36- {{ effectiveNoResultsText }}
37- </slot >
21+ <div v-for =" group in groupedOptions" :key =" group.groupTitle" class =" group" >
22+ <div v-if =" group.groupTitle" class =" group-title" >
23+ <slot name =" group" :group =" group.groupTitle" >
24+ {{ group.groupTitle }}
25+ </slot >
26+ </div >
27+
28+ <div
29+ v-for =" option in group.options"
30+ :key =" option.key"
31+ class =" option"
32+ :class =" [highlightedOptionKey === option.key && 'highlighted', option.ref && classForOption?.(option.ref)]"
33+ @mousemove =" handleOptionHover(option)"
34+ @mousedown =" selectOption(option)"
35+ >
36+ <slot name =" option" :option =" option" >
37+ <div class =" title" v-html =" option.title" />
38+ <div v-if =" option.subtitle" class =" subtitle" v-html =" option.subtitle" />
39+ </slot >
40+ </div >
41+ <div v-if =" !effectiveOptions.length && searchText" class =" no-results" >
42+ <slot name =" no-results" >
43+ {{ effectiveNoResultsText }}
44+ </slot >
45+ </div >
3846 </div >
3947 </template >
4048 </div >
4149 </div >
4250</template >
4351
4452<script lang="ts" setup generic =" T , V = T " >
45- import { debounce , isEqual } from ' lodash' ;
53+ import { debounce , groupBy , isEqual , uniq } from ' lodash' ;
4654import { computed , onMounted , type Ref , ref , watch } from ' vue' ;
4755
4856import { escapeHtml } from ' ../helpers/string' ;
@@ -69,6 +77,8 @@ const props = defineProps<{
6977 valueField? : keyof T ;
7078 valueExtractor? : (option : T ) => V ;
7179 labelField? : keyof T ;
80+ groupField? : keyof T ;
81+ groupFormatter? : (option : T ) => string ;
7282 formatter? : (option : T ) => string ;
7383 subtitleFormatter? : (option : T ) => string ;
7484 classForOption? : (option : T ) => string ;
@@ -129,16 +139,23 @@ const effectiveKeyExtractor = computed(() => {
129139 if (effectiveValueExtractor .value ) return (option : T ) => String (effectiveValueExtractor .value !(option ));
130140 return null ;
131141});
142+ const effectiveGroupFormatter = computed (() => {
143+ if (props .groupFormatter ) return props .groupFormatter ;
144+ if (props .groupField ) return (option : T ) => String (option [props .groupField ! ]);
145+ return null ;
146+ });
132147const effectiveFormatter = computed (() => {
133148 if (props .formatter ) return props .formatter ;
134149 if (props .labelField ) return (option : T ) => String (option [props .labelField ! ]);
135150 return (option : T ) => String (option );
136151});
137152
138153const allOptions = computed (() => [... effectivePrependOptions .value , ... loadedOptions .value , ... effectiveAppendOptions .value ]);
154+ const isGrouped = computed (() => !! (props .groupField || props .groupFormatter ));
139155
140156const optionsDescriptors = computed (() => {
141157 return allOptions .value .map ((option , index ) => {
158+ const group = effectiveGroupFormatter .value ?.(option );
142159 const title = effectiveFormatter .value (option );
143160 const subtitle = props .subtitleFormatter ?.(option );
144161 const strippedTitle = title ? title .trim ().toLowerCase () : ' ' ;
@@ -160,6 +177,7 @@ const optionsDescriptors = computed(() => {
160177
161178 return {
162179 key: effectiveKeyExtractor .value ?.(option ) ?? String (index ),
180+ group ,
163181 title ,
164182 subtitle ,
165183 searchContent: searchContent .join (' ' ),
@@ -208,6 +226,24 @@ const effectiveOptions = computed(() => {
208226 return options ;
209227});
210228
229+ const groupedOptions = computed (() => {
230+ if (! effectiveOptions .value [0 ]?.group ) {
231+ return [
232+ {
233+ groupTitle: ' ' ,
234+ options: effectiveOptions .value
235+ }
236+ ];
237+ }
238+
239+ const groupTitles = uniq (effectiveOptions .value .map (option => option .group ?? ' ' ));
240+ const groupedOptions = groupBy (effectiveOptions .value , option => option .group );
241+ return groupTitles .map (groupTitle => ({
242+ groupTitle ,
243+ options: groupedOptions [groupTitle ! ]
244+ }));
245+ });
246+
211247// watch props
212248watch (() => props .modelValue , handleValueChanged );
213249watch (
@@ -593,6 +629,11 @@ function focusNextInput() {
593629 overflow : auto ;
594630 z-index : 101 ;
595631
632+ .group-title {
633+ padding : 5px 8px ;
634+ color : #999 ;
635+ }
636+
596637 .option ,
597638 .no-results {
598639 padding : 5px 8px ;
0 commit comments