diff --git a/dist/format.d.ts b/dist/format.d.ts
index f1d95ca..f2c76cb 100644
--- a/dist/format.d.ts
+++ b/dist/format.d.ts
@@ -1,5 +1,5 @@
-import type { AutocompleteResponse, Suggestion } from "./types";
-export declare function lookupFilter(suggestion: Suggestion, _originalQuery: string, queryLowerCase: string): boolean;
+import type { AutocompleteResponse, ResolvedOptions, Suggestion } from "./types";
+export declare function lookupFilter(suggestion: Suggestion, _originalQuery: string, queryLowerCase: string, options?: ResolvedOptions): boolean;
export declare function transformResult(response: string | AutocompleteResponse): AutocompleteResponse;
-export declare function formatResult(suggestion: Suggestion, currentValue: string): string;
+export declare function formatResult(suggestion: Suggestion, currentValue: string, _index?: number, options?: ResolvedOptions): string;
export declare function formatGroup(_suggestion: Suggestion, category: string): string;
diff --git a/dist/jquery.autocomplete.esm.js b/dist/jquery.autocomplete.esm.js
index ae96284..e167a32 100644
--- a/dist/jquery.autocomplete.esm.js
+++ b/dist/jquery.autocomplete.esm.js
@@ -39,20 +39,28 @@ var keys = {
};
// src/format.ts
-function lookupFilter(suggestion, _originalQuery, queryLowerCase) {
+function lookupFilter(suggestion, _originalQuery, queryLowerCase, options) {
+ if (options?.ignoreDiacritics) {
+ return foldText(suggestion.value).text.indexOf(foldText(queryLowerCase).text) !== -1;
+ }
return suggestion.value.toLowerCase().indexOf(queryLowerCase) !== -1;
}
function transformResult(response) {
return typeof response === "string" ? JSON.parse(response) : response;
}
-function formatResult(suggestion, currentValue) {
+function formatResult(suggestion, currentValue, _index, options) {
if (!currentValue) {
const span = document.createElement("span");
span.textContent = suggestion.value;
return span.innerHTML;
}
+ if (options?.ignoreDiacritics) {
+ return highlightFolded(suggestion.value, currentValue);
+ }
const pattern = "(" + utils.escapeRegExChars(currentValue) + ")";
- return suggestion.value.replace(new RegExp(pattern, "gi"), "$1").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/<(\/?strong)>/g, "<$1>");
+ return escapeHtml(
+ suggestion.value.replace(new RegExp(pattern, "gi"), "$1")
+ ).replace(/<(\/?strong)>/g, "<$1>");
}
function formatGroup(_suggestion, category) {
const div = document.createElement("div");
@@ -60,6 +68,47 @@ function formatGroup(_suggestion, category) {
div.textContent = category;
return div.outerHTML;
}
+function escapeHtml(value) {
+ return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+function foldText(value) {
+ const folded = { text: "", starts: [], ends: [] };
+ let offset = 0;
+ for (const char of value) {
+ const end = offset + char.length;
+ const piece = char.normalize("NFD").replace(/\p{M}/gu, "").normalize("NFC").toLowerCase();
+ for (let k = folded.ends.length - 1; !piece && k >= 0 && folded.ends[k] === offset; k--) {
+ folded.ends[k] = end;
+ }
+ for (let i = 0; i < piece.length; i++) {
+ folded.starts.push(offset);
+ folded.ends.push(end);
+ }
+ folded.text += piece;
+ offset = end;
+ }
+ return folded;
+}
+function highlightFolded(value, currentValue) {
+ const query = foldText(currentValue).text;
+ if (!query) {
+ return escapeHtml(value);
+ }
+ const folded = foldText(value);
+ let html = "";
+ let last = 0;
+ let from = 0;
+ let hit;
+ while ((hit = folded.text.indexOf(query, from)) !== -1) {
+ const start = Math.max(folded.starts[hit], last);
+ const end = folded.ends[hit + query.length - 1];
+ html += escapeHtml(value.slice(last, start));
+ html += "" + escapeHtml(value.slice(start, end)) + "";
+ last = end;
+ from = hit + query.length;
+ }
+ return html + escapeHtml(value.slice(last));
+}
// src/defaults.ts
var noop = () => {
@@ -88,6 +137,7 @@ var defaults = {
triggerSelectOnValidInput: true,
preventBadQueries: true,
lookupFilter,
+ ignoreDiacritics: false,
paramName: "query",
transformResult,
showNoSuggestionNotice: false,
@@ -387,7 +437,9 @@ var _Autocomplete = class _Autocomplete {
const filter = options.lookupFilter;
const limit = parseInt(options.lookupLimit, 10);
const lookup = options.lookup;
- const matched = lookup.filter((suggestion) => filter(suggestion, query, queryLowerCase));
+ const matched = lookup.filter(
+ (suggestion) => filter(suggestion, query, queryLowerCase, options)
+ );
return {
suggestions: limit && matched.length > limit ? matched.slice(0, limit) : matched
};
@@ -510,7 +562,7 @@ var _Autocomplete = class _Autocomplete {
};
const html = this.suggestions.map((suggestion, i) => {
const group = groupBy ? formatGroupFn(suggestion) : "";
- return `${group}
${formatResultFn(suggestion, value, i)}
`;
+ return `${group}${formatResultFn(suggestion, value, i, options)}
`;
}).join("");
this.adjustContainerWidth();
this.$noSuggestionsContainer.detach();
diff --git a/dist/jquery.autocomplete.js b/dist/jquery.autocomplete.js
index 7b21e43..fbc365d 100644
--- a/dist/jquery.autocomplete.js
+++ b/dist/jquery.autocomplete.js
@@ -47,20 +47,28 @@
};
// src/format.ts
- function lookupFilter(suggestion, _originalQuery, queryLowerCase) {
+ function lookupFilter(suggestion, _originalQuery, queryLowerCase, options) {
+ if (options?.ignoreDiacritics) {
+ return foldText(suggestion.value).text.indexOf(foldText(queryLowerCase).text) !== -1;
+ }
return suggestion.value.toLowerCase().indexOf(queryLowerCase) !== -1;
}
function transformResult(response) {
return typeof response === "string" ? JSON.parse(response) : response;
}
- function formatResult(suggestion, currentValue) {
+ function formatResult(suggestion, currentValue, _index, options) {
if (!currentValue) {
const span = document.createElement("span");
span.textContent = suggestion.value;
return span.innerHTML;
}
+ if (options?.ignoreDiacritics) {
+ return highlightFolded(suggestion.value, currentValue);
+ }
const pattern = "(" + utils.escapeRegExChars(currentValue) + ")";
- return suggestion.value.replace(new RegExp(pattern, "gi"), "$1").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/<(\/?strong)>/g, "<$1>");
+ return escapeHtml(
+ suggestion.value.replace(new RegExp(pattern, "gi"), "$1")
+ ).replace(/<(\/?strong)>/g, "<$1>");
}
function formatGroup(_suggestion, category) {
const div = document.createElement("div");
@@ -68,6 +76,47 @@
div.textContent = category;
return div.outerHTML;
}
+ function escapeHtml(value) {
+ return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+ }
+ function foldText(value) {
+ const folded = { text: "", starts: [], ends: [] };
+ let offset = 0;
+ for (const char of value) {
+ const end = offset + char.length;
+ const piece = char.normalize("NFD").replace(/\p{M}/gu, "").normalize("NFC").toLowerCase();
+ for (let k = folded.ends.length - 1; !piece && k >= 0 && folded.ends[k] === offset; k--) {
+ folded.ends[k] = end;
+ }
+ for (let i = 0; i < piece.length; i++) {
+ folded.starts.push(offset);
+ folded.ends.push(end);
+ }
+ folded.text += piece;
+ offset = end;
+ }
+ return folded;
+ }
+ function highlightFolded(value, currentValue) {
+ const query = foldText(currentValue).text;
+ if (!query) {
+ return escapeHtml(value);
+ }
+ const folded = foldText(value);
+ let html = "";
+ let last = 0;
+ let from = 0;
+ let hit;
+ while ((hit = folded.text.indexOf(query, from)) !== -1) {
+ const start = Math.max(folded.starts[hit], last);
+ const end = folded.ends[hit + query.length - 1];
+ html += escapeHtml(value.slice(last, start));
+ html += "" + escapeHtml(value.slice(start, end)) + "";
+ last = end;
+ from = hit + query.length;
+ }
+ return html + escapeHtml(value.slice(last));
+ }
// src/defaults.ts
var noop = () => {
@@ -96,6 +145,7 @@
triggerSelectOnValidInput: true,
preventBadQueries: true,
lookupFilter,
+ ignoreDiacritics: false,
paramName: "query",
transformResult,
showNoSuggestionNotice: false,
@@ -395,7 +445,9 @@
const filter = options.lookupFilter;
const limit = parseInt(options.lookupLimit, 10);
const lookup = options.lookup;
- const matched = lookup.filter((suggestion) => filter(suggestion, query, queryLowerCase));
+ const matched = lookup.filter(
+ (suggestion) => filter(suggestion, query, queryLowerCase, options)
+ );
return {
suggestions: limit && matched.length > limit ? matched.slice(0, limit) : matched
};
@@ -518,7 +570,7 @@
};
const html = this.suggestions.map((suggestion, i) => {
const group = groupBy ? formatGroupFn(suggestion) : "";
- return `${group}${formatResultFn(suggestion, value, i)}
`;
+ return `${group}${formatResultFn(suggestion, value, i, options)}
`;
}).join("");
this.adjustContainerWidth();
this.$noSuggestionsContainer.detach();
diff --git a/dist/jquery.autocomplete.min.js b/dist/jquery.autocomplete.min.js
index 41af27b..d596702 100644
--- a/dist/jquery.autocomplete.min.js
+++ b/dist/jquery.autocomplete.min.js
@@ -15,6 +15,6 @@
factory(jQuery);
}
})(function ($) {
-"use strict";(()=>{var c=null;function C(r){c=r}var v={escapeRegExChars(r){return r.replace(/[|\\{}()[\]^$+*?.]/g,"\\$&")},createNode(r){let t=document.createElement("div");return t.className=r,t.style.position="absolute",t.style.display="none",t}},h={ESC:27,TAB:9,RETURN:13,LEFT:37,UP:38,RIGHT:39,DOWN:40};function x(r,t,e){return r.value.toLowerCase().indexOf(e)!==-1}function T(r){return typeof r=="string"?JSON.parse(r):r}function w(r,t){if(!t){let s=document.createElement("span");return s.textContent=r.value,s.innerHTML}let e="("+v.escapeRegExChars(t)+")";return r.value.replace(new RegExp(e,"gi"),"$1").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/<(\/?strong)>/g,"<$1>")}function R(r,t){let e=document.createElement("div");return e.className="autocomplete-group",e.textContent=t,e.outerHTML}var S=()=>{},A={ajaxSettings:{},autoSelectFirst:!1,appendTo:"body",width:"auto",minChars:1,maxHeight:300,deferRequestBy:0,params:{},formatResult:w,formatGroup:R,zIndex:9999,type:"GET",noCache:!1,onSearchStart:S,onSearchComplete:S,onSearchError:S,preserveInput:!1,containerClass:"autocomplete-suggestions",tabDisabled:!1,dataType:"text",triggerSelectOnValidInput:!0,preventBadQueries:!0,lookupFilter:x,paramName:"query",transformResult:T,showNoSuggestionNotice:!1,noSuggestionNotice:"No results",orientation:"bottom",forceFixPosition:!1};var g=class g{constructor(t,e){this.suggestions=[];this.badQueries=[];this.selectedIndex=-1;this.cachedResponse={};this.onChangeTimeout=null;this.isLocal=!1;this.classes={selected:"autocomplete-selected",suggestion:"autocomplete-suggestion"};this.hint=null;this.hintValue="";this.selection=null;this.currentRequest=null;this.element=t,this.el=c(t),this.currentValue=t.value,this.options=c.extend(!0,{},g.defaults,e),this.initialize(),this.setOptions(e)}initialize(){let t=this,e=`.${this.classes.suggestion}`,s=this.classes.selected,i=this.options;this.element.setAttribute("autocomplete","off"),this.$noSuggestionsContainer=c('').html(i.noSuggestionNotice),this.noSuggestionsContainer=this.$noSuggestionsContainer.get(0),this.suggestionsContainer=g.utils.createNode(i.containerClass),this.$container=c(this.suggestionsContainer),this.$container.appendTo(i.appendTo||"body"),i.width!=="auto"&&this.$container.css("width",i.width);let o=this.$container;o.on("mouseover.autocomplete",e,function(){t.activate(c(this).data("index"))}),o.on("click.autocomplete",e,function(){t.select(c(this).data("index"))}),o.on("mouseout.autocomplete",()=>{this.selectedIndex=-1,o.children(`.${s}`).removeClass(s)}),o.on("click.autocomplete",()=>{this.blurTimeoutId!==void 0&&clearTimeout(this.blurTimeoutId)}),this.fixPositionCapture=()=>{this.visible&&this.fixPosition()},c(window).on("resize.autocomplete",this.fixPositionCapture),this.el.on("keydown.autocomplete",n=>this.onKeyPress(n)),this.el.on("keyup.autocomplete",n=>this.onKeyUp(n)),this.el.on("blur.autocomplete",()=>this.onBlur()),this.el.on("focus.autocomplete",()=>this.onFocus()),this.el.on("change.autocomplete",n=>this.onKeyUp(n)),this.el.on("input.autocomplete",n=>this.onKeyUp(n))}onFocus(){this.disabled||(this.fixPosition(),this.el.val().length>=this.options.minChars&&this.onValueChange())}onBlur(){let t=this.options,e=this.getQuery(this.el.val());this.blurTimeoutId=setTimeout(()=>{this.hide(),this.selection&&this.currentValue!==e&&t.onInvalidateSelection?.call(this.element)},200)}abortAjax(){this.currentRequest&&(this.currentRequest.abort(),this.currentRequest=null)}setOptions(t){let e={...this.options,...t};this.isLocal=Array.isArray(e.lookup),this.isLocal&&(e.lookup=this.verifySuggestionsFormat(e.lookup)),e.orientation=this.validateOrientation(e.orientation,"bottom"),this.$container.css({"max-height":`${e.maxHeight}px`,width:`${e.width}px`,"z-index":e.zIndex}),this.options=e}clearCache(){this.cachedResponse={},this.badQueries=[]}clear(){this.clearCache(),this.currentValue="",this.suggestions=[]}disable(){this.disabled=!0,this.onChangeTimeout&&clearTimeout(this.onChangeTimeout),this.abortAjax()}enable(){this.disabled=!1}fixPosition(){let t=this.$container,e=t.parent().get(0);if(e!==document.body&&!this.options.forceFixPosition)return;let s=this.options.orientation,i=t.outerHeight()??0,o=this.el.outerHeight()??0,n=this.el.offset()??{top:0,left:0},a={top:n.top,left:n.left};if(s==="auto"){let l=c(window).height()??0,u=c(window).scrollTop()??0,p=-u+n.top-i,y=u+l-(n.top+o+i);s=Math.max(p,y)===p?"top":"bottom"}if(a.top+=s==="top"?-i:o,e!==document.body&&e!==void 0){let l=t.css("opacity");this.visible||t.css("opacity",0).show();let u=t.offsetParent().offset()??{top:0,left:0};a.top-=u.top,a.top+=e.scrollTop,a.left-=u.left,this.visible||t.css("opacity",l).hide()}this.options.width==="auto"&&(a.width=`${this.el.outerWidth()??0}px`),t.css(a)}isCursorAtEnd(){let t=this.el.val().length,{selectionStart:e}=this.element;return typeof e=="number"?e===t:!0}onKeyPress(t){if(!this.disabled&&!this.visible&&t.which===h.DOWN&&this.currentValue){this.suggest();return}if(!(this.disabled||!this.visible)){switch(t.which){case h.ESC:this.el.val(this.currentValue),this.hide();break;case h.RIGHT:if(this.hint&&this.options.onHint&&this.isCursorAtEnd()){this.selectHint();break}return;case h.TAB:if(this.hint&&this.options.onHint){this.selectHint();return}if(this.selectedIndex===-1){this.hide();return}if(this.select(this.selectedIndex),this.options.tabDisabled===!1)return;break;case h.RETURN:if(this.selectedIndex===-1){this.hide();return}this.select(this.selectedIndex);break;case h.UP:this.moveUp();break;case h.DOWN:this.moveDown();break;default:return}t.stopImmediatePropagation(),t.preventDefault()}}onKeyUp(t){this.disabled||t.which===h.UP||t.which===h.DOWN||(this.onChangeTimeout&&clearTimeout(this.onChangeTimeout),this.currentValue!==this.el.val()&&(this.findBestHint(),this.options.deferRequestBy>0?this.onChangeTimeout=setTimeout(()=>this.onValueChange(),this.options.deferRequestBy):this.onValueChange()))}onValueChange(){if(this.ignoreValueChange){this.ignoreValueChange=!1;return}let t=this.options,e=this.el.val(),s=this.getQuery(e);if(this.selection&&this.currentValue!==s&&(this.selection=null,t.onInvalidateSelection?.call(this.element)),this.onChangeTimeout&&clearTimeout(this.onChangeTimeout),this.currentValue=e,this.selectedIndex=-1,t.triggerSelectOnValidInput&&this.isExactMatch(s)){this.select(0);return}s.lengthi(l,t,s));return{suggestions:o&&a.length>o?a.slice(0,o):a}}getSuggestions(t){let e=this.options,s=e.serviceUrl,i,o;if(e.params[e.paramName]=t,e.onSearchStart.call(this.element,e.params)===!1)return;let n=e.ignoreParams?null:e.params;if(typeof e.lookup=="function"){e.lookup(t,a=>{let l=this.verifySuggestionsFormat(a.suggestions);this.suggestions=l,e.onSearchComplete.call(this.element,t,l),this.suggest()});return}if(this.isLocal?i=this.getSuggestionsLocal(t):(typeof s=="function"&&(s=s.call(this.element,t)),o=`${s}?${c.param(n??{})}`,i=this.cachedResponse[o]),i&&Array.isArray(i.suggestions))this.suggestions=i.suggestions,e.onSearchComplete.call(this.element,t,i.suggestions),this.suggest();else if(this.isBadQuery(t))e.onSearchComplete.call(this.element,t,[]);else{this.abortAjax();let a={url:s,data:n??void 0,type:e.type,dataType:e.dataType,...e.ajaxSettings};this.currentRequest=c.ajax(a).done(l=>{this.currentRequest=null;let u=e.transformResult(l,t);u.suggestions=this.verifySuggestionsFormat(u.suggestions),e.onSearchComplete.call(this.element,t,u.suggestions),this.processResponse(u,t,o)}).fail((l,u,p)=>{e.onSearchError.call(this.element,t,l,u,p)})}}isBadQuery(t){return this.options.preventBadQueries?this.badQueries.some(e=>t.indexOf(e)===0):!1}hide(){this.options.onHide&&this.visible&&this.options.onHide.call(this.element,this.$container),this.visible=!1,this.selectedIndex=-1,this.onChangeTimeout&&clearTimeout(this.onChangeTimeout),this.$container.hide(),this.onHint(null)}groupSuggestionsByCategory(t,e){let s=new Map;for(let i of t){let o=i.data[e],n=s.get(o);n?n.push(i):s.set(o,[i])}return Array.from(s.values()).flat()}suggest(){if(!this.suggestions.length){this.options.showNoSuggestionNotice?this.noSuggestions():this.hide();return}let t=this.options,{groupBy:e,formatResult:s,beforeRender:i}=t,o=this.getQuery(this.currentValue),n=this.classes.suggestion,a=this.classes.selected,l=this.$container;if(t.triggerSelectOnValidInput&&this.isExactMatch(o)){this.select(0);return}e&&(this.suggestions=this.groupSuggestionsByCategory(this.suggestions,e));let u,p=d=>{let f=d.data[e];return u===f?"":(u=f,t.formatGroup(d,u))},y=this.suggestions.map((d,f)=>`${e?p(d):""}${s(d,o,f)}
`).join("");this.adjustContainerWidth(),this.$noSuggestionsContainer.detach(),l.html(y),i?.call(this.element,l,this.suggestions),this.fixPosition(),l.show(),t.autoSelectFirst&&(this.selectedIndex=0,l.scrollTop(0),l.children(`.${n}`).first().addClass(a)),this.visible=!0,this.findBestHint()}noSuggestions(){let{beforeRender:t}=this.options,e=this.$container;this.adjustContainerWidth(),this.$noSuggestionsContainer.detach(),e.empty().append(this.$noSuggestionsContainer),t?.call(this.element,e,this.suggestions),this.fixPosition(),e.show(),this.visible=!0}adjustContainerWidth(){let{width:t}=this.options;if(t==="auto"){let e=this.el.outerWidth()??0;this.$container.css("width",e>0?e:300)}else t==="flex"&&this.$container.css("width","")}findBestHint(){let t=this.el.val().toLowerCase();if(!t)return;let e=this.suggestions.find(s=>s.value.toLowerCase().indexOf(t)===0)??null;this.onHint(e)}onHint(t){let{onHint:e}=this.options,s=t?this.currentValue+t.value.substr(this.currentValue.length):"";this.hintValue!==s&&(this.hintValue=s,this.hint=t,e?.call(this.element,s))}verifySuggestionsFormat(t){return t.length&&typeof t[0]=="string"?t.map(e=>({value:e,data:null})):t.map(e=>typeof e.value=="string"?e:{...e,value:String(e.value)})}validateOrientation(t,e){let s=(t||"").trim().toLowerCase();return s==="auto"||s==="top"||s==="bottom"?s:e}processResponse(t,e,s){let i=this.options;t.suggestions=this.verifySuggestionsFormat(t.suggestions),i.noCache||(this.cachedResponse[s]=t,i.preventBadQueries&&!t.suggestions.length&&e&&this.badQueries.push(e)),e===this.getQuery(this.currentValue)&&(this.suggestions=t.suggestions,this.suggest())}activate(t){let e=this.classes.selected,s=this.$container,i=s.find(`.${this.classes.suggestion}`);if(s.find(`.${e}`).removeClass(e),this.selectedIndex=t,this.selectedIndex!==-1&&i.length>this.selectedIndex){let o=i.get(this.selectedIndex);return c(o).addClass(e),o}return null}selectHint(){this.select(this.suggestions.indexOf(this.hint))}select(t){this.hide(),this.onSelect(t)}moveUp(){if(this.selectedIndex!==-1){if(this.selectedIndex===0){this.$container.children(`.${this.classes.suggestion}`).first().removeClass(this.classes.selected),this.selectedIndex=-1,this.ignoreValueChange=!1,this.el.val(this.currentValue),this.findBestHint();return}this.adjustScroll(this.selectedIndex-1)}}moveDown(){this.selectedIndex!==this.suggestions.length-1&&this.adjustScroll(this.selectedIndex+1)}adjustScroll(t){let e=this.activate(t);if(!e)return;let s=c(e).outerHeight()??0,i=e.offsetTop,o=this.$container,n=o.scrollTop()??0,a=n+this.options.maxHeight-s;ia&&o.scrollTop(i-this.options.maxHeight+s),this.options.preserveInput||(this.ignoreValueChange=!0,this.el.val(this.getValue(this.suggestions[t].value))),this.onHint(null)}onSelect(t){let e=this.options.onSelect,s=this.suggestions[t];this.currentValue=this.getValue(s.value),this.currentValue!==this.el.val()&&!this.options.preserveInput&&this.el.val(this.currentValue),this.onHint(null),this.suggestions=[],this.selection=s,e?.call(this.element,s)}getValue(t){let{delimiter:e}=this.options;if(!e)return t;let s=this.currentValue,i=s.split(e);return i.length===1?t:s.substr(0,s.length-i[i.length-1].length)+t}dispose(){this.el.off(".autocomplete").removeData("autocomplete"),this.fixPositionCapture&&c(window).off("resize.autocomplete",this.fixPositionCapture),this.$container.remove()}};g.defaults=A,g.utils=v;var m=g;var b="autocomplete";function H(r){C(r),r.Autocomplete=m,r.fn.devbridgeAutocomplete=function(t,e){return arguments.length?this.each(function(){let s=r(this),i=s.data(b);typeof t=="string"?i&&typeof i[t]=="function"&&i[t](e):(i&&i.dispose&&i.dispose(),i=new m(this,t),s.data(b,i))}):this.first().data(b)},r.fn.autocomplete||(r.fn.autocomplete=r.fn.devbridgeAutocomplete)}H($);})();
+"use strict";(()=>{var c=null;function T(r){c=r}var y={escapeRegExChars(r){return r.replace(/[|\\{}()[\]^$+*?.]/g,"\\$&")},createNode(r){let t=document.createElement("div");return t.className=r,t.style.position="absolute",t.style.display="none",t}},h={ESC:27,TAB:9,RETURN:13,LEFT:37,UP:38,RIGHT:39,DOWN:40};function w(r,t,e,s){return s?.ignoreDiacritics?x(r.value).text.indexOf(x(e).text)!==-1:r.value.toLowerCase().indexOf(e)!==-1}function R(r){return typeof r=="string"?JSON.parse(r):r}function A(r,t,e,s){if(!t){let o=document.createElement("span");return o.textContent=r.value,o.innerHTML}if(s?.ignoreDiacritics)return O(r.value,t);let i="("+y.escapeRegExChars(t)+")";return m(r.value.replace(new RegExp(i,"gi"),"$1")).replace(/<(\/?strong)>/g,"<$1>")}function H(r,t){let e=document.createElement("div");return e.className="autocomplete-group",e.textContent=t,e.outerHTML}function m(r){return r.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}function x(r){let t={text:"",starts:[],ends:[]},e=0;for(let s of r){let i=e+s.length,o=s.normalize("NFD").replace(/\p{M}/gu,"").normalize("NFC").toLowerCase();for(let n=t.ends.length-1;!o&&n>=0&&t.ends[n]===e;n--)t.ends[n]=i;for(let n=0;n"+m(r.slice(l,u))+"",o=u,n=a+e.length}return i+m(r.slice(o))}var S=()=>{},k={ajaxSettings:{},autoSelectFirst:!1,appendTo:"body",width:"auto",minChars:1,maxHeight:300,deferRequestBy:0,params:{},formatResult:A,formatGroup:H,zIndex:9999,type:"GET",noCache:!1,onSearchStart:S,onSearchComplete:S,onSearchError:S,preserveInput:!1,containerClass:"autocomplete-suggestions",tabDisabled:!1,dataType:"text",triggerSelectOnValidInput:!0,preventBadQueries:!0,lookupFilter:w,ignoreDiacritics:!1,paramName:"query",transformResult:R,showNoSuggestionNotice:!1,noSuggestionNotice:"No results",orientation:"bottom",forceFixPosition:!1};var g=class g{constructor(t,e){this.suggestions=[];this.badQueries=[];this.selectedIndex=-1;this.cachedResponse={};this.onChangeTimeout=null;this.isLocal=!1;this.classes={selected:"autocomplete-selected",suggestion:"autocomplete-suggestion"};this.hint=null;this.hintValue="";this.selection=null;this.currentRequest=null;this.element=t,this.el=c(t),this.currentValue=t.value,this.options=c.extend(!0,{},g.defaults,e),this.initialize(),this.setOptions(e)}initialize(){let t=this,e=`.${this.classes.suggestion}`,s=this.classes.selected,i=this.options;this.element.setAttribute("autocomplete","off"),this.$noSuggestionsContainer=c('').html(i.noSuggestionNotice),this.noSuggestionsContainer=this.$noSuggestionsContainer.get(0),this.suggestionsContainer=g.utils.createNode(i.containerClass),this.$container=c(this.suggestionsContainer),this.$container.appendTo(i.appendTo||"body"),i.width!=="auto"&&this.$container.css("width",i.width);let o=this.$container;o.on("mouseover.autocomplete",e,function(){t.activate(c(this).data("index"))}),o.on("click.autocomplete",e,function(){t.select(c(this).data("index"))}),o.on("mouseout.autocomplete",()=>{this.selectedIndex=-1,o.children(`.${s}`).removeClass(s)}),o.on("click.autocomplete",()=>{this.blurTimeoutId!==void 0&&clearTimeout(this.blurTimeoutId)}),this.fixPositionCapture=()=>{this.visible&&this.fixPosition()},c(window).on("resize.autocomplete",this.fixPositionCapture),this.el.on("keydown.autocomplete",n=>this.onKeyPress(n)),this.el.on("keyup.autocomplete",n=>this.onKeyUp(n)),this.el.on("blur.autocomplete",()=>this.onBlur()),this.el.on("focus.autocomplete",()=>this.onFocus()),this.el.on("change.autocomplete",n=>this.onKeyUp(n)),this.el.on("input.autocomplete",n=>this.onKeyUp(n))}onFocus(){this.disabled||(this.fixPosition(),this.el.val().length>=this.options.minChars&&this.onValueChange())}onBlur(){let t=this.options,e=this.getQuery(this.el.val());this.blurTimeoutId=setTimeout(()=>{this.hide(),this.selection&&this.currentValue!==e&&t.onInvalidateSelection?.call(this.element)},200)}abortAjax(){this.currentRequest&&(this.currentRequest.abort(),this.currentRequest=null)}setOptions(t){let e={...this.options,...t};this.isLocal=Array.isArray(e.lookup),this.isLocal&&(e.lookup=this.verifySuggestionsFormat(e.lookup)),e.orientation=this.validateOrientation(e.orientation,"bottom"),this.$container.css({"max-height":`${e.maxHeight}px`,width:`${e.width}px`,"z-index":e.zIndex}),this.options=e}clearCache(){this.cachedResponse={},this.badQueries=[]}clear(){this.clearCache(),this.currentValue="",this.suggestions=[]}disable(){this.disabled=!0,this.onChangeTimeout&&clearTimeout(this.onChangeTimeout),this.abortAjax()}enable(){this.disabled=!1}fixPosition(){let t=this.$container,e=t.parent().get(0);if(e!==document.body&&!this.options.forceFixPosition)return;let s=this.options.orientation,i=t.outerHeight()??0,o=this.el.outerHeight()??0,n=this.el.offset()??{top:0,left:0},a={top:n.top,left:n.left};if(s==="auto"){let l=c(window).height()??0,u=c(window).scrollTop()??0,p=-u+n.top-i,b=u+l-(n.top+o+i);s=Math.max(p,b)===p?"top":"bottom"}if(a.top+=s==="top"?-i:o,e!==document.body&&e!==void 0){let l=t.css("opacity");this.visible||t.css("opacity",0).show();let u=t.offsetParent().offset()??{top:0,left:0};a.top-=u.top,a.top+=e.scrollTop,a.left-=u.left,this.visible||t.css("opacity",l).hide()}this.options.width==="auto"&&(a.width=`${this.el.outerWidth()??0}px`),t.css(a)}isCursorAtEnd(){let t=this.el.val().length,{selectionStart:e}=this.element;return typeof e=="number"?e===t:!0}onKeyPress(t){if(!this.disabled&&!this.visible&&t.which===h.DOWN&&this.currentValue){this.suggest();return}if(!(this.disabled||!this.visible)){switch(t.which){case h.ESC:this.el.val(this.currentValue),this.hide();break;case h.RIGHT:if(this.hint&&this.options.onHint&&this.isCursorAtEnd()){this.selectHint();break}return;case h.TAB:if(this.hint&&this.options.onHint){this.selectHint();return}if(this.selectedIndex===-1){this.hide();return}if(this.select(this.selectedIndex),this.options.tabDisabled===!1)return;break;case h.RETURN:if(this.selectedIndex===-1){this.hide();return}this.select(this.selectedIndex);break;case h.UP:this.moveUp();break;case h.DOWN:this.moveDown();break;default:return}t.stopImmediatePropagation(),t.preventDefault()}}onKeyUp(t){this.disabled||t.which===h.UP||t.which===h.DOWN||(this.onChangeTimeout&&clearTimeout(this.onChangeTimeout),this.currentValue!==this.el.val()&&(this.findBestHint(),this.options.deferRequestBy>0?this.onChangeTimeout=setTimeout(()=>this.onValueChange(),this.options.deferRequestBy):this.onValueChange()))}onValueChange(){if(this.ignoreValueChange){this.ignoreValueChange=!1;return}let t=this.options,e=this.el.val(),s=this.getQuery(e);if(this.selection&&this.currentValue!==s&&(this.selection=null,t.onInvalidateSelection?.call(this.element)),this.onChangeTimeout&&clearTimeout(this.onChangeTimeout),this.currentValue=e,this.selectedIndex=-1,t.triggerSelectOnValidInput&&this.isExactMatch(s)){this.select(0);return}s.lengthi(l,t,s,e));return{suggestions:o&&a.length>o?a.slice(0,o):a}}getSuggestions(t){let e=this.options,s=e.serviceUrl,i,o;if(e.params[e.paramName]=t,e.onSearchStart.call(this.element,e.params)===!1)return;let n=e.ignoreParams?null:e.params;if(typeof e.lookup=="function"){e.lookup(t,a=>{let l=this.verifySuggestionsFormat(a.suggestions);this.suggestions=l,e.onSearchComplete.call(this.element,t,l),this.suggest()});return}if(this.isLocal?i=this.getSuggestionsLocal(t):(typeof s=="function"&&(s=s.call(this.element,t)),o=`${s}?${c.param(n??{})}`,i=this.cachedResponse[o]),i&&Array.isArray(i.suggestions))this.suggestions=i.suggestions,e.onSearchComplete.call(this.element,t,i.suggestions),this.suggest();else if(this.isBadQuery(t))e.onSearchComplete.call(this.element,t,[]);else{this.abortAjax();let a={url:s,data:n??void 0,type:e.type,dataType:e.dataType,...e.ajaxSettings};this.currentRequest=c.ajax(a).done(l=>{this.currentRequest=null;let u=e.transformResult(l,t);u.suggestions=this.verifySuggestionsFormat(u.suggestions),e.onSearchComplete.call(this.element,t,u.suggestions),this.processResponse(u,t,o)}).fail((l,u,p)=>{e.onSearchError.call(this.element,t,l,u,p)})}}isBadQuery(t){return this.options.preventBadQueries?this.badQueries.some(e=>t.indexOf(e)===0):!1}hide(){this.options.onHide&&this.visible&&this.options.onHide.call(this.element,this.$container),this.visible=!1,this.selectedIndex=-1,this.onChangeTimeout&&clearTimeout(this.onChangeTimeout),this.$container.hide(),this.onHint(null)}groupSuggestionsByCategory(t,e){let s=new Map;for(let i of t){let o=i.data[e],n=s.get(o);n?n.push(i):s.set(o,[i])}return Array.from(s.values()).flat()}suggest(){if(!this.suggestions.length){this.options.showNoSuggestionNotice?this.noSuggestions():this.hide();return}let t=this.options,{groupBy:e,formatResult:s,beforeRender:i}=t,o=this.getQuery(this.currentValue),n=this.classes.suggestion,a=this.classes.selected,l=this.$container;if(t.triggerSelectOnValidInput&&this.isExactMatch(o)){this.select(0);return}e&&(this.suggestions=this.groupSuggestionsByCategory(this.suggestions,e));let u,p=d=>{let f=d.data[e];return u===f?"":(u=f,t.formatGroup(d,u))},b=this.suggestions.map((d,f)=>`${e?p(d):""}${s(d,o,f,t)}
`).join("");this.adjustContainerWidth(),this.$noSuggestionsContainer.detach(),l.html(b),i?.call(this.element,l,this.suggestions),this.fixPosition(),l.show(),t.autoSelectFirst&&(this.selectedIndex=0,l.scrollTop(0),l.children(`.${n}`).first().addClass(a)),this.visible=!0,this.findBestHint()}noSuggestions(){let{beforeRender:t}=this.options,e=this.$container;this.adjustContainerWidth(),this.$noSuggestionsContainer.detach(),e.empty().append(this.$noSuggestionsContainer),t?.call(this.element,e,this.suggestions),this.fixPosition(),e.show(),this.visible=!0}adjustContainerWidth(){let{width:t}=this.options;if(t==="auto"){let e=this.el.outerWidth()??0;this.$container.css("width",e>0?e:300)}else t==="flex"&&this.$container.css("width","")}findBestHint(){let t=this.el.val().toLowerCase();if(!t)return;let e=this.suggestions.find(s=>s.value.toLowerCase().indexOf(t)===0)??null;this.onHint(e)}onHint(t){let{onHint:e}=this.options,s=t?this.currentValue+t.value.substr(this.currentValue.length):"";this.hintValue!==s&&(this.hintValue=s,this.hint=t,e?.call(this.element,s))}verifySuggestionsFormat(t){return t.length&&typeof t[0]=="string"?t.map(e=>({value:e,data:null})):t.map(e=>typeof e.value=="string"?e:{...e,value:String(e.value)})}validateOrientation(t,e){let s=(t||"").trim().toLowerCase();return s==="auto"||s==="top"||s==="bottom"?s:e}processResponse(t,e,s){let i=this.options;t.suggestions=this.verifySuggestionsFormat(t.suggestions),i.noCache||(this.cachedResponse[s]=t,i.preventBadQueries&&!t.suggestions.length&&e&&this.badQueries.push(e)),e===this.getQuery(this.currentValue)&&(this.suggestions=t.suggestions,this.suggest())}activate(t){let e=this.classes.selected,s=this.$container,i=s.find(`.${this.classes.suggestion}`);if(s.find(`.${e}`).removeClass(e),this.selectedIndex=t,this.selectedIndex!==-1&&i.length>this.selectedIndex){let o=i.get(this.selectedIndex);return c(o).addClass(e),o}return null}selectHint(){this.select(this.suggestions.indexOf(this.hint))}select(t){this.hide(),this.onSelect(t)}moveUp(){if(this.selectedIndex!==-1){if(this.selectedIndex===0){this.$container.children(`.${this.classes.suggestion}`).first().removeClass(this.classes.selected),this.selectedIndex=-1,this.ignoreValueChange=!1,this.el.val(this.currentValue),this.findBestHint();return}this.adjustScroll(this.selectedIndex-1)}}moveDown(){this.selectedIndex!==this.suggestions.length-1&&this.adjustScroll(this.selectedIndex+1)}adjustScroll(t){let e=this.activate(t);if(!e)return;let s=c(e).outerHeight()??0,i=e.offsetTop,o=this.$container,n=o.scrollTop()??0,a=n+this.options.maxHeight-s;ia&&o.scrollTop(i-this.options.maxHeight+s),this.options.preserveInput||(this.ignoreValueChange=!0,this.el.val(this.getValue(this.suggestions[t].value))),this.onHint(null)}onSelect(t){let e=this.options.onSelect,s=this.suggestions[t];this.currentValue=this.getValue(s.value),this.currentValue!==this.el.val()&&!this.options.preserveInput&&this.el.val(this.currentValue),this.onHint(null),this.suggestions=[],this.selection=s,e?.call(this.element,s)}getValue(t){let{delimiter:e}=this.options;if(!e)return t;let s=this.currentValue,i=s.split(e);return i.length===1?t:s.substr(0,s.length-i[i.length-1].length)+t}dispose(){this.el.off(".autocomplete").removeData("autocomplete"),this.fixPositionCapture&&c(window).off("resize.autocomplete",this.fixPositionCapture),this.$container.remove()}};g.defaults=k,g.utils=y;var v=g;var C="autocomplete";function I(r){T(r),r.Autocomplete=v,r.fn.devbridgeAutocomplete=function(t,e){return arguments.length?this.each(function(){let s=r(this),i=s.data(C);typeof t=="string"?i&&typeof i[t]=="function"&&i[t](e):(i&&i.dispose&&i.dispose(),i=new v(this,t),s.data(C,i))}):this.first().data(C)},r.fn.autocomplete||(r.fn.autocomplete=r.fn.devbridgeAutocomplete)}I($);})();
});
diff --git a/dist/types.d.ts b/dist/types.d.ts
index ae85662..f82f224 100644
--- a/dist/types.d.ts
+++ b/dist/types.d.ts
@@ -11,9 +11,9 @@ export type LookupArray = string[] | Suggestion[];
export type ServiceUrl = string | ((this: HTMLElement, query: string) => string);
export type Orientation = "auto" | "top" | "bottom";
export type WidthOption = number | "auto" | "flex";
-export type LookupFilter = (suggestion: Suggestion, originalQuery: string, queryLowerCase: string) => boolean;
+export type LookupFilter = (suggestion: Suggestion, originalQuery: string, queryLowerCase: string, options?: ResolvedOptions) => boolean;
export type TransformResult = (response: any, originalQuery: string) => AutocompleteResponse;
-export type FormatResult = (suggestion: Suggestion, currentValue: string, index?: number) => string;
+export type FormatResult = (suggestion: Suggestion, currentValue: string, index?: number, options?: ResolvedOptions) => string;
export type FormatGroup = (suggestion: Suggestion, category: string) => string;
export type SearchStartCallback = (this: HTMLElement, params: Record) => unknown;
export type SearchCompleteCallback = (this: HTMLElement, query: string, suggestions: Suggestion[]) => void;
@@ -47,6 +47,7 @@ interface DefaultedOptions {
triggerSelectOnValidInput: boolean;
preventBadQueries: boolean;
lookupFilter: LookupFilter;
+ ignoreDiacritics: boolean;
paramName: string;
transformResult: TransformResult;
showNoSuggestionNotice: boolean;
diff --git a/readme.md b/readme.md
index 1263385..5c075f4 100644
--- a/readme.md
+++ b/readme.md
@@ -47,7 +47,8 @@ tables below.
| `showNoSuggestionNotice` | `false` | If `true`, displays a label when no suggestions match |
| `noSuggestionNotice` | `"No results"` | Text, HTML string, `Element`, or jQuery object used as the no-match label |
| `groupBy` | optional | Property on `suggestion.data` to group results by |
-| `formatResult` | optional | `function (suggestion, currentValue)` — custom HTML for a single suggestion entry |
+| `formatResult` | optional | `function (suggestion, currentValue, index, options)` — custom HTML for a single suggestion entry. The default wraps matches in `` |
+| `ignoreDiacritics` | `false` | If `true`, the default `lookupFilter` and `formatResult` ignore accents, so `cafe` matches and highlights `Café`. Other letters (e.g. `ß`) are not folded. With Ajax lookups, which results match is still up to the server |
| `formatGroup` | optional | `function (suggestion, category)` — custom HTML for a group header |
| `beforeRender` | optional | `function (container, suggestions)` — called before the dropdown is shown; mutate the DOM here if needed |
| `onInvalidateSelection` | optional | `function ()` — fires when the input changes after a selection was made. `this` is the input element |
@@ -76,7 +77,7 @@ tables below.
| Setting | Default | Description |
| :--- | :--- | :--- |
| `lookup` | optional | Either an array of suggestions or a callback `function (query, done)`. Arrays may be strings or `{ value, data }` objects |
-| `lookupFilter` | optional | `function (suggestion, query, queryLowerCase)` — filter predicate. Default is a case-insensitive substring match |
+| `lookupFilter` | optional | `function (suggestion, query, queryLowerCase, options)` — filter predicate. Default is a case-insensitive substring match (accent-insensitive with `ignoreDiacritics`) |
| `lookupLimit` | unlimited | Maximum number of local matches to display |
A suggestion is an object of the form `{ value: string, data: any }`. `data`
@@ -212,6 +213,21 @@ Minimal CSS to get started:
`cursor: pointer` on `.autocomplete-suggestion` is required for tap-to-select
to fire on mobile Safari — see issue #542.
+To highlight matches with a different element, e.g. ``, wrap the
+default formatter. It escapes everything except its own `` tags, so
+the swap is safe; pass `index` and `options` through so `ignoreDiacritics`
+keeps working:
+
+```js
+$('#autocomplete').devbridgeAutocomplete({
+ lookup: countries,
+ formatResult: (suggestion, currentValue, index, options) =>
+ $.Autocomplete.defaults
+ .formatResult(suggestion, currentValue, index, options)
+ .replace(/<(\/?)strong>/g, '<$1mark>'),
+});
+```
+
## Response format
The server must respond with JSON in this shape:
diff --git a/src/Autocomplete.ts b/src/Autocomplete.ts
index 61a13d9..43838dc 100644
--- a/src/Autocomplete.ts
+++ b/src/Autocomplete.ts
@@ -392,7 +392,9 @@ export class Autocomplete {
const limit = parseInt(options.lookupLimit as string, 10);
const lookup = options.lookup as Suggestion[];
- const matched = lookup.filter((suggestion) => filter(suggestion, query, queryLowerCase));
+ const matched = lookup.filter((suggestion) =>
+ filter(suggestion, query, queryLowerCase, options)
+ );
return {
suggestions: limit && matched.length > limit ? matched.slice(0, limit) : matched,
@@ -543,7 +545,7 @@ export class Autocomplete {
const html = this.suggestions
.map((suggestion, i) => {
const group = groupBy ? formatGroupFn(suggestion) : "";
- return `${group}${formatResultFn(suggestion, value, i)}
`;
+ return `${group}${formatResultFn(suggestion, value, i, options)}
`;
})
.join("");
diff --git a/src/defaults.ts b/src/defaults.ts
index 069e7ac..ad079da 100644
--- a/src/defaults.ts
+++ b/src/defaults.ts
@@ -31,6 +31,7 @@ export const defaults: ResolvedOptions = {
triggerSelectOnValidInput: true,
preventBadQueries: true,
lookupFilter,
+ ignoreDiacritics: false,
paramName: "query",
transformResult,
showNoSuggestionNotice: false,
diff --git a/src/format.ts b/src/format.ts
index 4a6a595..0ec437e 100644
--- a/src/format.ts
+++ b/src/format.ts
@@ -1,11 +1,15 @@
-import type { AutocompleteResponse, Suggestion } from "./types";
+import type { AutocompleteResponse, ResolvedOptions, Suggestion } from "./types";
import { utils } from "./utils";
export function lookupFilter(
suggestion: Suggestion,
_originalQuery: string,
- queryLowerCase: string
+ queryLowerCase: string,
+ options?: ResolvedOptions
): boolean {
+ if (options?.ignoreDiacritics) {
+ return foldText(suggestion.value).text.indexOf(foldText(queryLowerCase).text) !== -1;
+ }
return suggestion.value.toLowerCase().indexOf(queryLowerCase) !== -1;
}
@@ -13,7 +17,12 @@ export function transformResult(response: string | AutocompleteResponse): Autoco
return typeof response === "string" ? (JSON.parse(response) as AutocompleteResponse) : response;
}
-export function formatResult(suggestion: Suggestion, currentValue: string): string {
+export function formatResult(
+ suggestion: Suggestion,
+ currentValue: string,
+ _index?: number,
+ options?: ResolvedOptions
+): string {
if (!currentValue) {
// Same escaping channel as formatGroup — let the browser handle entities
// so an HTML-bearing suggestion.value can't break out of the text node.
@@ -22,15 +31,15 @@ export function formatResult(suggestion: Suggestion, currentValue: string): stri
return span.innerHTML;
}
+ if (options?.ignoreDiacritics) {
+ return highlightFolded(suggestion.value, currentValue);
+ }
+
const pattern = "(" + utils.escapeRegExChars(currentValue) + ")";
- return suggestion.value
- .replace(new RegExp(pattern, "gi"), "$1")
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/<(\/?strong)>/g, "<$1>");
+ return escapeHtml(
+ suggestion.value.replace(new RegExp(pattern, "gi"), "$1")
+ ).replace(/<(\/?strong)>/g, "<$1>");
}
export function formatGroup(_suggestion: Suggestion, category: string): string {
@@ -39,3 +48,67 @@ export function formatGroup(_suggestion: Suggestion, category: string): string {
div.textContent = category;
return div.outerHTML;
}
+
+function escapeHtml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+interface FoldedText {
+ text: string;
+ // For each UTF-16 unit of `text`, the [start, end) range of the source
+ // character it came from — lets a match in folded text be mapped back.
+ starts: number[];
+ ends: number[];
+}
+
+// Lowercases and strips combining marks one character at a time, so every
+// folded unit traces back to exactly one source character. Filtering and
+// highlighting share this function so they always agree on what matches.
+function foldText(value: string): FoldedText {
+ const folded: FoldedText = { text: "", starts: [], ends: [] };
+ let offset = 0;
+ for (const char of value) {
+ const end = offset + char.length;
+ const piece = char.normalize("NFD").replace(/\p{M}/gu, "").normalize("NFC").toLowerCase();
+ // A standalone combining mark folds to nothing; attach it to the
+ // preceding character so a highlight doesn't split them apart.
+ for (let k = folded.ends.length - 1; !piece && k >= 0 && folded.ends[k] === offset; k--) {
+ folded.ends[k] = end;
+ }
+ for (let i = 0; i < piece.length; i++) {
+ folded.starts.push(offset);
+ folded.ends.push(end);
+ }
+ folded.text += piece;
+ offset = end;
+ }
+ return folded;
+}
+
+function highlightFolded(value: string, currentValue: string): string {
+ const query = foldText(currentValue).text;
+ if (!query) {
+ return escapeHtml(value);
+ }
+
+ const folded = foldText(value);
+ let html = "";
+ let last = 0;
+ let from = 0;
+ let hit: number;
+ while ((hit = folded.text.indexOf(query, from)) !== -1) {
+ // A match can begin inside a character that folds to several units
+ // and was already consumed by the previous match.
+ const start = Math.max(folded.starts[hit]!, last);
+ const end = folded.ends[hit + query.length - 1]!;
+ html += escapeHtml(value.slice(last, start));
+ html += "" + escapeHtml(value.slice(start, end)) + "";
+ last = end;
+ from = hit + query.length;
+ }
+ return html + escapeHtml(value.slice(last));
+}
diff --git a/src/types.ts b/src/types.ts
index 8e37f07..d9182fd 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -18,7 +18,8 @@ export type WidthOption = number | "auto" | "flex";
export type LookupFilter = (
suggestion: Suggestion,
originalQuery: string,
- queryLowerCase: string
+ queryLowerCase: string,
+ options?: ResolvedOptions
) => boolean;
// `response` is whatever `$.ajax` resolved with — its shape is defined by the
@@ -27,7 +28,12 @@ export type LookupFilter = (
// consumer-annotated `(response: MyResponse) => ...` (parameter contravariance).
export type TransformResult = (response: any, originalQuery: string) => AutocompleteResponse;
-export type FormatResult = (suggestion: Suggestion, currentValue: string, index?: number) => string;
+export type FormatResult = (
+ suggestion: Suggestion,
+ currentValue: string,
+ index?: number,
+ options?: ResolvedOptions
+) => string;
export type FormatGroup = (suggestion: Suggestion, category: string) => string;
@@ -81,6 +87,7 @@ interface DefaultedOptions {
triggerSelectOnValidInput: boolean;
preventBadQueries: boolean;
lookupFilter: LookupFilter;
+ ignoreDiacritics: boolean;
paramName: string;
transformResult: TransformResult;
showNoSuggestionNotice: boolean;
diff --git a/test/autocomplete.test.js b/test/autocomplete.test.js
index a2bf7bf..beb1947 100644
--- a/test/autocomplete.test.js
+++ b/test/autocomplete.test.js
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
-import { formatGroup, formatResult } from "../src/format.ts";
+import { formatGroup, formatResult, lookupFilter } from "../src/format.ts";
const $ = globalThis.jQuery;
@@ -462,6 +462,69 @@ describe("Autocomplete Async — empty originalQuery", () => {
});
});
+describe("ignoreDiacritics", () => {
+ const on = { ignoreDiacritics: true };
+ const fmt = (value, query) => formatResult({ value, data: null }, query, 0, on);
+ const matches = (value, query) =>
+ lookupFilter({ value, data: null }, query, query.toLowerCase(), on);
+
+ it("is off by default: accented values don't match an unaccented query", () => {
+ expect(lookupFilter({ value: "Café", data: null }, "cafe", "cafe")).toBe(false);
+ expect(formatResult({ value: "Café", data: null }, "cafe")).toBe("Café");
+ });
+
+ it("filters regardless of accents in either the value or the query", () => {
+ expect(matches("Café au lait", "cafe")).toBe(true);
+ expect(matches("Cafe au lait", "CAFÉ")).toBe(true);
+ expect(matches("Crème brûlée", "brulee")).toBe(true);
+ expect(matches("Café", "tea")).toBe(false);
+ });
+
+ it("highlights the original accented characters", () => {
+ expect(fmt("Café au lait", "cafe")).toBe("Café au lait");
+ expect(fmt("Crème brûlée", "BRULEE")).toBe("Crème brûlée");
+ });
+
+ it("keeps decomposed combining marks inside the highlight", () => {
+ const decomposed = "Café noir";
+ expect(fmt(decomposed, "cafe")).toBe("Café noir");
+ });
+
+ it("highlights every occurrence", () => {
+ expect(fmt("Élan élan", "elan")).toBe("Élan élan");
+ });
+
+ it("leaves non-diacritic letters alone", () => {
+ expect(matches("Straße", "strasse")).toBe(false);
+ expect(fmt("Straße", "straße")).toBe("Straße");
+ });
+
+ it("escapes HTML around and inside the highlight", () => {
+ expect(fmt('"Café"', "cafe")).toBe(
+ "<b>"Café"</b>"
+ );
+ });
+
+ it("escapes without highlighting when the query folds to nothing", () => {
+ expect(fmt("Café", "́")).toBe("<i>Café</i>");
+ });
+
+ it("is applied end to end through the lookup option", () => {
+ const input = document.createElement("input");
+ const autocomplete = new $.Autocomplete(input, {
+ lookup: ["Café", "Tea"],
+ ignoreDiacritics: true,
+ triggerSelectOnValidInput: false,
+ });
+
+ input.value = "cafe";
+ autocomplete.onValueChange();
+
+ expect(autocomplete.suggestions.map((s) => s.value)).toEqual(["Café"]);
+ expect(autocomplete.suggestionsContainer.innerHTML).toContain("Café");
+ });
+});
+
describe("Autocomplete", () => {
afterEach(() => {
$(".autocomplete-suggestions").hide();