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();