From 15d0950c023f27bb8b2811ff17f004e6a6f42305 Mon Sep 17 00:00:00 2001 From: Tomas Kirda Date: Mon, 28 Sep 2026 21:27:50 -0500 Subject: [PATCH] feat: add ignoreDiacritics option for accent-insensitive matching (#893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in (default false). When on, the default lookupFilter and formatResult fold case and strip combining marks per character, so "cafe" matches and highlights "Café". A per-character fold map lets highlights wrap the original accented text, including decomposed marks. Both callbacks now also receive the resolved options (additive, optional parameter) so the defaults can read the flag. The README documents the option and a wrapper for highlighting with instead of . Co-Authored-By: Claude Opus 5.5 --- dist/format.d.ts | 6 +-- dist/jquery.autocomplete.esm.js | 62 ++++++++++++++++++++-- dist/jquery.autocomplete.js | 62 ++++++++++++++++++++-- dist/jquery.autocomplete.min.js | 2 +- dist/types.d.ts | 5 +- readme.md | 20 ++++++- src/Autocomplete.ts | 6 ++- src/defaults.ts | 1 + src/format.ts | 93 +++++++++++++++++++++++++++++---- src/types.ts | 11 +++- test/autocomplete.test.js | 65 ++++++++++++++++++++++- 11 files changed, 300 insertions(+), 33 deletions(-) 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();