/*! * Search.Box - standalone SearchBox control (ES5, IE10 compatible) * Exposes constructor as global.Search.Box * * Features: * - API compatible-ish with WinJS.UI.SearchBox: properties (placeholderText, queryText, chooseSuggestionOnEnter, disabled) * - Events: querychanged, querysubmitted, resultsuggestionchosen, suggestionsrequested * - supports both `element.addEventListener("querychanged", handler)` and `instance.onquerychanged = handler` * - Methods: setSuggestions(array), clearSuggestions(), dispose(), setLocalContentSuggestionSettings(settings) (noop) * - Suggestions kinds: Query (0), Result (1), Separator (2) OR string names 'query'/'result'/'separator' * - Hit highlighting: uses item.hits if provided, otherwise simple substring match of current input * - No WinRT / WinJS dependency * * Usage: * var box = new Search.Box(hostElement, options); * box.setSuggestions([{ kind: 0, text: "hello" }, ...]); */ (function(global) { "use strict"; // Ensure namespace if (!global.Search) { global.Search = {}; } // Suggestion kinds var SuggestionKind = { Query: 0, Result: 1, Separator: 2 }; // Utility: create id function uniqueId(prefix) { return prefix + Math.random().toString(36).slice(2); } // Simple CustomEvent fallback for IE10 function createCustomEvent(type, detail) { var ev; try { ev = document.createEvent("CustomEvent"); ev.initCustomEvent(type, true, true, detail || {}); } catch (e) { ev = document.createEvent("Event"); ev.initEvent(type, true, true); ev.detail = detail || {}; } return ev; } // Constructor function SearchBox(element, options) { element = element || document.createElement("div"); if (element.__searchBoxInstance) { throw new Error("Search.Box: duplicate construction on same element"); } element.__searchBoxInstance = this; // DOM elements this._root = element; this._input = document.createElement("input"); this._input.type = "search"; this._button = document.createElement("div"); this._button.tabIndex = -1; this._flyout = document.createElement("div"); this._repeater = document.createElement("div"); // container for suggestion items this._flyout.style.display = "none"; // state this._suggestions = []; this._currentSelectedIndex = -1; // fake focus/selection index this._currentFocusedIndex = -1; // navigation focus index this._prevQueryText = ""; this._chooseSuggestionOnEnter = false; this._disposed = false; this._lastKeyPressLanguage = ""; // classes follow WinJS naming where convenient (so your existing CSS can still be used) this._root.className = (this._root.className ? this._root.className + " " : "") + "win-searchbox"; this._input.className = "win-searchbox-input"; this._button.className = "win-searchbox-button"; this._flyout.className = "win-searchbox-flyout"; this._repeater.className = "win-searchbox-repeater"; // assemble this._flyout.appendChild(this._repeater); this._root.appendChild(this._input); this._root.appendChild(this._button); this._root.appendChild(this._flyout); // accessibility basics this._root.setAttribute("role", "group"); this._input.setAttribute("role", "textbox"); this._button.setAttribute("role", "button"); this._repeater.setAttribute("role", "listbox"); if (!this._repeater.id) { this._repeater.id = uniqueId("search_repeater_"); } this._input.setAttribute("aria-controls", this._repeater.id); this._repeater.setAttribute("aria-live", "polite"); // user-assignable event handlers (older style) this.onquerychanged = null; this.onquerysubmitted = null; this.onresultsuggestionchosen = null; this.onsuggestionsrequested = null; // wire events this._wireEvents(); // options options = options || {}; if (options.placeholderText) this.placeholderText = options.placeholderText; if (options.queryText) this.queryText = options.queryText; if (options.chooseSuggestionOnEnter) this.chooseSuggestionOnEnter = !!options.chooseSuggestionOnEnter; if (options.disabled) this.disabled = !!options.disabled; // new events this.ontextchanged = null; } // Prototype SearchBox.prototype = { // Properties get element() { return this._root; }, get placeholderText() { return this._input.placeholder; }, set placeholderText(value) { this._input.placeholder = value || ""; }, get queryText() { return this._input.value; }, set queryText(value) { this._input.value = value == null ? "" : value; }, get chooseSuggestionOnEnter() { return this._chooseSuggestionOnEnter; }, set chooseSuggestionOnEnter(v) { this._chooseSuggestionOnEnter = !!v; this._updateButtonClass(); }, get disabled() { return !!this._input.disabled; }, set disabled(v) { var val = !!v; if (val === this.disabled) return; this._input.disabled = val; try { this._button.disabled = val; } catch (e) {} if (val) { this._root.className = (this._root.className + " win-searchbox-disabled").trim(); this.hideFlyout(); } else { this._root.className = this._root.className.replace(/\bwin-searchbox-disabled\b/g, "").trim(); } }, // Public methods setSuggestions: function(arr) { // Expect array of objects with keys: kind (0/1/2 or 'query'/'result'/'separator'), text, detailText, tag, imageUrl, hits this._suggestions = (arr && arr.slice(0)) || []; this._currentSelectedIndex = -1; this._currentFocusedIndex = -1; this._renderSuggestions(); if (this._suggestions.length) this.showFlyout(); else this.hideFlyout(); }, clearSuggestions: function() { this.setSuggestions([]); }, showFlyout: function() { if (!this._suggestions || this._suggestions.length === 0) return; this._flyout.style.display = "block"; this._updateButtonClass(); }, hideFlyout: function() { this._flyout.style.display = "none"; this._updateButtonClass(); }, dispose: function() { if (this._disposed) return; // detach event listeners by cloning elements (simple way) var newRoot = this._root.cloneNode(true); if (this._root.parentNode) { this._root.parentNode.replaceChild(newRoot, this._root); } try { delete this._root.__searchBoxInstance; } catch (e) {} this._disposed = true; }, setLocalContentSuggestionSettings: function(settings) { // No-op in non-WinRT environment; kept for API compatibility. }, // Internal / rendering _wireEvents: function() { var that = this; this._input.addEventListener("input", function(ev) { that._onInputChange(ev); }, false); this._input.addEventListener("keydown", function(ev) { that._onKeyDown(ev); }, false); this._input.addEventListener("keypress", function(ev) { // capture locale if available try { that._lastKeyPressLanguage = ev.locale || that._lastKeyPressLanguage; } catch (e) {} }, false); this._input.addEventListener("focus", function() { if (that._suggestions.length) { that.showFlyout(); that._updateFakeFocus(); } that._root.className = (that._root.className + " win-searchbox-input-focus").trim(); that._updateButtonClass(); }, false); this._input.addEventListener("blur", function() { // small timeout to allow suggestion click to process setTimeout(function() { if (!that._root.contains(document.activeElement)) { that.hideFlyout(); that._root.className = that._root.className.replace(/\bwin-searchbox-input-focus\b/g, "").trim(); that._currentFocusedIndex = -1; that._currentSelectedIndex = -1; } }, 0); }, false); this._button.addEventListener("click", function(ev) { that._input.focus(); that._submitQuery(that._input.value, ev); that.hideFlyout(); }, false); // delegate click for suggestions: attach on repeater container (works in IE10) this._repeater.addEventListener("click", function(ev) { var el = ev.target; // climb until we find child with data-index while (el && el !== that._repeater) { if (el.hasAttribute && el.hasAttribute("data-index")) break; el = el.parentNode; } if (el && el !== that._repeater) { var idx = parseInt(el.getAttribute("data-index"), 10); var item = that._suggestions[idx]; if (item) { that._input.focus(); that._processSuggestionChosen(item, ev); } } }, false); }, _onInputChange: function(ev) { if (this.disabled) return; var v = this._input.value; this._emit("textchanged", { text: v }, this.ontextchanged); var changed = (v !== this._prevQueryText); this._prevQueryText = v; // fire querychanged var evDetail = { language: this._getBrowserLanguage(), queryText: v, linguisticDetails: { queryTextAlternatives: [], queryTextCompositionStart: 0, queryTextCompositionLength: 0 } }; this._emit("querychanged", evDetail, this.onquerychanged); // fire suggestionsrequested - allow client to call setSuggestions var suggestionsDetail = { queryText: v, language: this._getBrowserLanguage(), setSuggestions: (function(thatRef) { return function(arr) { thatRef.setSuggestions(arr || []); }; })(this) }; this._emit("suggestionsrequested", suggestionsDetail, this.onsuggestionsrequested); }, _submitQuery: function(queryText, ev) { var detail = { language: this._getBrowserLanguage(), queryText: queryText, keyModifiers: this._getKeyModifiers(ev) }; this._emit("querysubmitted", detail, this.onquerysubmitted); }, _processSuggestionChosen: function(item, ev) { // normalize kind var kind = item.kind; if (typeof kind === "string") { if (kind.toLowerCase() === "query") kind = SuggestionKind.Query; else if (kind.toLowerCase() === "result") kind = SuggestionKind.Result; else if (kind.toLowerCase() === "separator") kind = SuggestionKind.Separator; } this.queryText = item.text || ""; if (kind === SuggestionKind.Query || kind === undefined) { // choose query -> submit this._submitQuery(item.text || "", ev); } else if (kind === SuggestionKind.Result) { this._emit("resultsuggestionchosen", { tag: item.tag, keyModifiers: this._getKeyModifiers(ev), storageFile: null }, this.onresultsuggestionchosen); } this.hideFlyout(); }, _renderSuggestions: function() { // clear repeater while (this._repeater.firstChild) this._repeater.removeChild(this._repeater.firstChild); var frag = document.createDocumentFragment(); for (var i = 0; i < this._suggestions.length; i++) { var s = this._suggestions[i]; var itemEl = this._renderSuggestion(s, i); frag.appendChild(itemEl); } this._repeater.appendChild(frag); this._updateFakeFocus(); }, _renderSuggestion: function(item, index) { var that = this; var kind = item.kind; if (typeof kind === "string") { kind = kind.toLowerCase() === "query" ? SuggestionKind.Query : kind.toLowerCase() === "result" ? SuggestionKind.Result : kind.toLowerCase() === "separator" ? SuggestionKind.Separator : kind; } var root = document.createElement("div"); root.setAttribute("data-index", index); root.id = this._repeater.id + "_" + index; if (kind === SuggestionKind.Separator) { root.className = "win-searchbox-suggestion-separator"; if (item.text) { var textEl = document.createElement("div"); textEl.innerText = item.text; textEl.setAttribute("aria-hidden", "true"); root.appendChild(textEl); } root.insertAdjacentHTML("beforeend", "