Skip to content

Commit cffb26c

Browse files
committed
feat(Autocomplete): add custom templates support
1 parent 94e86ff commit cffb26c

File tree

3 files changed

+184
-5
lines changed

3 files changed

+184
-5
lines changed

docs/assets/js/partials/snippets.js

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,114 @@ export default () => {
277277
}
278278
// js-docs-end autocomplete-external-data
279279

280+
// js-docs-start autocomplete-custom-options
281+
const myAutocompleteCountries = document.getElementById('myAutocompleteCountries')
282+
const myAutocompleteCountriesAndCities = document.getElementById('myAutocompleteCountriesAndCities')
283+
284+
if (myAutocompleteCountries && myAutocompleteCountriesAndCities) {
285+
const countries = [
286+
{
287+
value: 'pl',
288+
label: 'Poland',
289+
flag: '🇵🇱'
290+
},
291+
{
292+
value: 'de',
293+
label: 'Germany',
294+
flag: '🇩🇪'
295+
},
296+
{
297+
value: 'us',
298+
label: 'United States',
299+
flag: '🇺🇸'
300+
},
301+
{
302+
value: 'es',
303+
label: 'Spain',
304+
flag: '🇪🇸'
305+
},
306+
{
307+
value: 'gb',
308+
label: 'United Kingdom',
309+
flag: '🇬🇧'
310+
}
311+
]
312+
313+
const cities = [
314+
{
315+
label: 'United States',
316+
code: 'us',
317+
flag: '🇺🇸',
318+
options: [
319+
{
320+
value: 'au',
321+
label: 'Austin'
322+
},
323+
{
324+
value: 'ch',
325+
label: 'Chicago'
326+
},
327+
{
328+
value: 'la',
329+
label: 'Los Angeles'
330+
},
331+
{
332+
value: 'ny',
333+
label: 'New York'
334+
},
335+
{
336+
value: 'sa',
337+
label: 'San Jose'
338+
}
339+
]
340+
},
341+
{
342+
label: 'United Kingdom',
343+
code: 'gb',
344+
flag: '🇬🇧',
345+
options: [
346+
{
347+
value: 'li',
348+
label: 'Liverpool'
349+
},
350+
{
351+
value: 'lo',
352+
label: 'London'
353+
},
354+
{
355+
value: 'ma',
356+
label: 'Manchester'
357+
}
358+
]
359+
}
360+
]
361+
362+
new coreui.Autocomplete(myAutocompleteCountries, {
363+
cleaner: true,
364+
indicator: true,
365+
options: countries,
366+
optionsTemplate(option) {
367+
return `<div class="d-flex align-items-center gap-2"><span class="fs-5">${option.flag}</span><span>${option.label}</span></div>`
368+
},
369+
placeholder: 'Select country',
370+
showHints: true,
371+
search: 'global'
372+
})
373+
374+
new coreui.Autocomplete(myAutocompleteCountriesAndCities, {
375+
cleaner: true,
376+
indicator: true,
377+
options: cities,
378+
optionsGroupsTemplate(optionGroup) {
379+
return `<div class="d-flex align-items-center gap-2"><span class="text-body fs-5">${optionGroup.flag}</span><span>${optionGroup.label}</span></div>`
380+
},
381+
placeholder: 'Select city',
382+
showHints: true,
383+
search: 'global'
384+
})
385+
}
386+
// js-docs-end autocomplete-custom-options
387+
280388
// -------------------------------
281389
// Multi Selects
282390
// -------------------------------

docs/content/forms/autocomplete.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,26 @@ Enable a cleaner button to quickly clear input element:
216216
<div data-coreui-toggle="autocomplete" data-coreui-cleaner="true" data-coreui-options="Angular, Bootstrap, React.js, Vue.js" data-coreui-placeholder="With cleaner button..."></div>
217217
{{< /example >}}
218218

219+
## Custom templates
220+
221+
The CoreUI Bootstrap Autocomplete Component provides the flexibility to personalize options and group labels by utilizing custom templates. You can easily customize the options using the `optionsTemplate`, and for groups, you can use `optionsGroupsTemplate`, as demonstrated in the examples below:
222+
223+
{{< example stackblitz_pro="true" stackblitz_add_js="true">}}
224+
<div class="row">
225+
<div class="col-md-6">
226+
<div id="myAutocompleteCountries"></div>
227+
</div>
228+
<div class="col-md-6">
229+
<div id="myAutocompleteCountriesAndCities"></div>
230+
</div>
231+
</div>
232+
{{< /example >}}
233+
234+
We use the following JavaScript to set up our autocomplete:
235+
236+
{{< js-docs name="autocomplete-custom-options" file="docs/assets/js/partials/snippets.js" >}}
237+
238+
219239
## Usage
220240

221241
{{< bootstrap-compatibility >}}
@@ -261,21 +281,27 @@ const autoCompleteList = autoCompleteElementList.map(autoCompleteEl => {
261281
{{< bs-table >}}
262282
| Name | Type | Default | Description |
263283
| --- | --- | --- | --- |
284+
| `allowList` | object | `DefaultAllowlist` | Object containing allowed tags and attributes for HTML sanitization when using custom templates. |
264285
| `allowOnlyDefinedOptions` | boolean | `false` | Restricts selection to only predefined options when set to `true`. |
265286
| `ariaCleanerLabel` | string | `'Clear selection'` | Accessible label for the cleaner button, read by screen readers. |
266287
| `ariaIndicatorLabel` | string | `'Toggle visibility of options menu'` | Accessible label for the indicator button, read by screen readers. |
267288
| `cleaner` | boolean | `false` | Enables the selection cleaner button. |
268289
| `clearSearchOnSelect` | boolean | `true` | Clears the search input when an option is selected. |
269290
| `container` | string, element, boolean | `false` | Appends the dropdown to a specific element. Example: `container: 'body'`. |
270291
| `disabled` | boolean | `false` | Disables the component when set to `true`. |
271-
| `highlightOptionsOnSearch` | boolean | `true` | Highlights matching text in options during search. |
292+
| `highlightOptionsOnSearch` | boolean | `false` | Highlights matching text in options during search. |
293+
| `id` | string, null | `null` | Sets a custom ID for the component. If not provided, a unique ID is auto-generated. |
272294
| `indicator` | boolean | `false` | Enables the selection indicator button. |
273295
| `invalid` | boolean | `false` | Applies invalid styling to the component. |
274296
| `name` | string, null | `null` | Sets the name attribute for the input element. |
275297
| `options` | boolean, array | `false` | Array of options or option objects to populate the dropdown. |
298+
| `optionsGroupsTemplate` | function, null | `null` | Custom template function for rendering option group labels. Receives the group object as parameter. |
276299
| `optionsMaxHeight` | number, string | `'auto'` | Sets the maximum height of the options dropdown. |
300+
| `optionsTemplate` | function, null | `null` | Custom template function for rendering individual options. Receives the option object as parameter. |
277301
| `placeholder` | string, null | `null` | Placeholder text displayed in the input field. |
278302
| `required` | boolean | `false` | Makes the input field required for form validation. |
303+
| `sanitize` | boolean | `true` | Enables HTML sanitization for custom templates to prevent XSS attacks. |
304+
| `sanitizeFn` | function, null | `null` | Custom sanitization function. If provided, it will be used instead of the built-in sanitizer. |
279305
| `search` | array, string, null | `null` | Enables search functionality. Use `'global'` for global search across the component and `'external'` when options are provided from external sources. |
280306
| `searchNoResultsLabel` | string | `false` | Text displayed when no search results are found. |
281307
| `showHints` | boolean | `false` | Shows completion hints as users type. |

js/src/autocomplete.js

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import BaseComponent from './base-component.js'
1010
import Data from './dom/data.js'
1111
import EventHandler from './dom/event-handler.js'
1212
import SelectorEngine from './dom/selector-engine.js'
13+
import { DefaultAllowlist, sanitizeHtml } from './util/sanitizer.js'
1314
import {
1415
defineJQueryPlugin,
1516
getNextActiveElement,
@@ -76,6 +77,7 @@ const SELECTOR_OPTIONS_EMPTY = '.autocomplete-options-empty'
7677
const SELECTOR_VISIBLE_ITEMS = '.autocomplete-options .autocomplete-option:not(.disabled):not(:disabled)'
7778

7879
const Default = {
80+
allowList: DefaultAllowlist,
7981
allowOnlyDefinedOptions: false,
8082
ariaCleanerLabel: 'Clear selection',
8183
ariaIndicatorLabel: 'Toggle visibility of options menu',
@@ -89,9 +91,13 @@ const Default = {
8991
invalid: false,
9092
name: null,
9193
options: false,
94+
optionsGroupsTemplate: null,
9295
optionsMaxHeight: 'auto',
96+
optionsTemplate: null,
9397
placeholder: null,
9498
required: false,
99+
sanitize: true,
100+
sanitizeFn: null,
95101
search: null,
96102
searchNoResultsLabel: false,
97103
showHints: false,
@@ -100,6 +106,7 @@ const Default = {
100106
}
101107

102108
const DefaultType = {
109+
allowList: 'object',
103110
allowOnlyDefinedOptions: 'boolean',
104111
ariaCleanerLabel: 'string',
105112
ariaIndicatorLabel: 'string',
@@ -113,9 +120,13 @@ const DefaultType = {
113120
invalid: 'boolean',
114121
name: '(string|null)',
115122
options: '(array|null)',
123+
optionsGroupsTemplate: '(function|null)',
116124
optionsMaxHeight: '(number|string)',
125+
optionsTemplate: '(function|null)',
117126
placeholder: '(string|null)',
118127
required: 'boolean',
128+
sanitize: 'boolean',
129+
sanitizeFn: '(null|function)',
119130
search: '(array|string|null)',
120131
searchNoResultsLabel: ('boolean|string'),
121132
showHints: 'boolean',
@@ -468,18 +479,33 @@ class Autocomplete extends BaseComponent {
468479
const _options = []
469480
for (const option of options) {
470481
if (option.options && Array.isArray(option.options)) {
482+
const customGroupProperties = { ...option }
483+
484+
delete customGroupProperties.label
485+
delete customGroupProperties.options
486+
471487
_options.push({
488+
...customGroupProperties,
472489
label: option.label,
473490
options: this._getOptionsFromConfig(option.options)
474491
})
492+
475493
continue
476494
}
477495

478496
const label = typeof option === 'string' ? option : option.label
479497
const value = option.value ?? (typeof option === 'string' ? option : option.label)
480498
const isSelected = option.selected || (this._config.value && this._config.value === value)
481499

500+
const customProperties = typeof option === 'object' ? { ...option } : {}
501+
502+
delete customProperties.label
503+
delete customProperties.value
504+
delete customProperties.selected
505+
delete customProperties.disabled
506+
482507
_options.push({
508+
...customProperties,
483509
label,
484510
value,
485511
...isSelected && { selected: true },
@@ -662,14 +688,21 @@ class Autocomplete extends BaseComponent {
662688
optgroup.setAttribute('role', 'group')
663689

664690
const optgrouplabel = document.createElement('div')
665-
optgrouplabel.textContent = option.label
691+
if (this._config.optionsGroupsTemplate && typeof this._config.optionsGroupsTemplate === 'function') {
692+
optgrouplabel.innerHTML = this._config.sanitize ?
693+
sanitizeHtml(this._config.optionsGroupsTemplate(option), this._config.allowList, this._config.sanitizeFn) :
694+
this._config.optionsGroupsTemplate(option)
695+
} else {
696+
optgrouplabel.textContent = option.label
697+
}
698+
666699
optgrouplabel.classList.add(CLASS_NAME_OPTGROUP_LABEL)
667700
optgroup.append(optgrouplabel)
668701

669702
this._createOptions(optgroup, option.options)
670703
parentElement.append(optgroup)
671704

672-
return
705+
continue
673706
}
674707

675708
const optionDiv = document.createElement('div')
@@ -684,6 +717,10 @@ class Autocomplete extends BaseComponent {
684717
optionDiv.tabIndex = 0
685718
if (this._isExternalSearch() && this._config.highlightOptionsOnSearch && this._search) {
686719
optionDiv.innerHTML = this._highlightOption(option.label)
720+
} else if (this._config.optionsTemplate && typeof this._config.optionsTemplate === 'function') {
721+
optionDiv.innerHTML = this._config.sanitize ?
722+
sanitizeHtml(this._config.optionsTemplate(option), this._config.allowList, this._config.sanitizeFn) :
723+
this._config.optionsTemplate(option)
687724
} else {
688725
optionDiv.textContent = option.label
689726
}
@@ -693,10 +730,18 @@ class Autocomplete extends BaseComponent {
693730
}
694731

695732
_onOptionsClick(element) {
696-
if (!element.classList.contains(CLASS_NAME_OPTION) || element.classList.contains(CLASS_NAME_LABEL)) {
733+
if (element.classList.contains(CLASS_NAME_LABEL)) {
697734
return
698735
}
699736

737+
if (!element.classList.contains(CLASS_NAME_OPTION)) {
738+
element = element.closest(SELECTOR_OPTION)
739+
740+
if (!element) {
741+
return
742+
}
743+
}
744+
700745
const value = String(element.dataset.value)
701746
const foundOption = this._findOptionByValue(value)
702747

@@ -804,7 +849,7 @@ class Autocomplete extends BaseComponent {
804849
if (option.textContent.toLowerCase().indexOf(this._search) === -1) {
805850
option.style.display = 'none'
806851
} else {
807-
if (this._config.highlightOptionsOnSearch) {
852+
if (this._config.highlightOptionsOnSearch && !this._config.optionsTemplate) {
808853
option.innerHTML = this._highlightOption(option.textContent)
809854
}
810855

0 commit comments

Comments
 (0)