зеркало из https://github.com/mozilla/gecko-dev.git
Bug 612453 - Provide search suggestions on Firefox Start Page (about:home) (part 3, searchSuggestionUI and about:home). r=MattN
This commit is contained in:
Родитель
f5a7cdd231
Коммит
94376cba8e
|
@ -310,10 +310,16 @@ function onSearchSubmit(aEvent)
|
|||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
aEvent.preventDefault();
|
||||
gSearchSuggestionController.addInputValueToFormHistory();
|
||||
|
||||
if (aEvent) {
|
||||
aEvent.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let gSearchSuggestionController;
|
||||
|
||||
function setupSearchEngine()
|
||||
{
|
||||
// The "autofocus" attribute doesn't focus the form element
|
||||
|
@ -341,6 +347,12 @@ function setupSearchEngine()
|
|||
searchText.placeholder = searchEngineName;
|
||||
}
|
||||
|
||||
if (!gSearchSuggestionController) {
|
||||
gSearchSuggestionController =
|
||||
new SearchSuggestionUIController(searchText, searchText.parentNode,
|
||||
onSearchSubmit);
|
||||
}
|
||||
gSearchSuggestionController.engineName = searchEngineName;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -24,10 +24,14 @@
|
|||
<link rel="icon" type="image/png" id="favicon"
|
||||
href="chrome://branding/content/icon32.png"/>
|
||||
<link rel="stylesheet" type="text/css" media="all"
|
||||
href="chrome://browser/content/searchSuggestionUI.css"/>
|
||||
<link rel="stylesheet" type="text/css" media="all" defer="defer"
|
||||
href="chrome://browser/content/abouthome/aboutHome.css"/>
|
||||
|
||||
<script type="text/javascript;version=1.8"
|
||||
src="chrome://browser/content/abouthome/aboutHome.js"/>
|
||||
<script type="text/javascript;version=1.8"
|
||||
src="chrome://browser/content/searchSuggestionUI.js"/>
|
||||
</head>
|
||||
|
||||
<body dir="&locale.dir;">
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
.searchSuggestionTable {
|
||||
background-color: hsla(0,0%,100%,.99);
|
||||
border: 1px solid;
|
||||
border-color: hsla(210,54%,20%,.15) hsla(210,54%,20%,.17) hsla(210,54%,20%,.2);
|
||||
border-spacing: 0;
|
||||
border-top: 0;
|
||||
box-shadow: 0 1px 0 hsla(210,65%,9%,.02) inset,
|
||||
0 0 2px hsla(210,65%,9%,.1) inset,
|
||||
0 1px 0 hsla(0,0%,100%,.2);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
text-align: start;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.searchSuggestionRow {
|
||||
cursor: default;
|
||||
margin: 0;
|
||||
max-width: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.searchSuggestionRow.formHistory {
|
||||
color: hsl(210,100%,40%);
|
||||
}
|
||||
|
||||
.searchSuggestionRow.selected {
|
||||
background-color: hsl(210,100%,40%);
|
||||
color: hsl(0,0%,100%);
|
||||
}
|
||||
|
||||
.searchSuggestionEntry {
|
||||
margin: 0;
|
||||
max-width: inherit;
|
||||
overflow: hidden;
|
||||
padding: 6px 8px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.searchSuggestionEntry > span.typed {
|
||||
font-weight: bold;
|
||||
}
|
|
@ -0,0 +1,379 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
||||
* You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
"use strict";
|
||||
|
||||
this.SearchSuggestionUIController = (function () {
|
||||
|
||||
const MAX_DISPLAYED_SUGGESTIONS = 6;
|
||||
const SUGGESTION_ID_PREFIX = "searchSuggestion";
|
||||
const CSS_URI = "chrome://browser/content/searchSuggestionUI.css";
|
||||
|
||||
const HTML_NS = "http://www.w3.org/1999/xhtml";
|
||||
|
||||
/**
|
||||
* Creates a new object that manages search suggestions and their UI for a text
|
||||
* box.
|
||||
*
|
||||
* The UI consists of an html:table that's inserted into the DOM after the given
|
||||
* text box and styled so that it appears as a dropdown below the text box.
|
||||
*
|
||||
* @param inputElement
|
||||
* Search suggestions will be based on the text in this text box.
|
||||
* Assumed to be an html:input. xul:textbox is untested but might work.
|
||||
* @param tableParent
|
||||
* The suggestion table is appended as a child to this element. Since
|
||||
* the table is absolutely positioned and its top and left values are set
|
||||
* to be relative to the top and left of the page, either the parent and
|
||||
* all its ancestors should not be positioned elements (i.e., their
|
||||
* positions should be "static"), or the parent's position should be the
|
||||
* top left of the page.
|
||||
* @param onClick
|
||||
* A function that's called when a search suggestion is clicked. Ideally
|
||||
* we could call submit() on inputElement's ancestor form, but that
|
||||
* doesn't trigger submit listeners.
|
||||
* @param idPrefix
|
||||
* The IDs of elements created by the object will be prefixed with this
|
||||
* string.
|
||||
*/
|
||||
function SearchSuggestionUIController(inputElement, tableParent, onClick=null,
|
||||
idPrefix="") {
|
||||
this.input = inputElement;
|
||||
this.onClick = onClick;
|
||||
this._idPrefix = idPrefix;
|
||||
|
||||
let tableID = idPrefix + "searchSuggestionTable";
|
||||
this.input.autocomplete = "off";
|
||||
this.input.setAttribute("aria-autocomplete", "true");
|
||||
this.input.setAttribute("aria-controls", tableID);
|
||||
tableParent.appendChild(this._makeTable(tableID));
|
||||
|
||||
this.input.addEventListener("keypress", this);
|
||||
this.input.addEventListener("input", this);
|
||||
this.input.addEventListener("focus", this);
|
||||
this.input.addEventListener("blur", this);
|
||||
window.addEventListener("ContentSearchService", this);
|
||||
|
||||
this._stickyInputValue = "";
|
||||
this._hideSuggestions();
|
||||
}
|
||||
|
||||
SearchSuggestionUIController.prototype = {
|
||||
|
||||
// The timeout (ms) of the remote suggestions. Corresponds to
|
||||
// SearchSuggestionController.remoteTimeout. Uses
|
||||
// SearchSuggestionController's default timeout if falsey.
|
||||
remoteTimeout: undefined,
|
||||
|
||||
get engineName() {
|
||||
return this._engineName;
|
||||
},
|
||||
|
||||
set engineName(val) {
|
||||
this._engineName = val;
|
||||
if (val && document.activeElement == this.input) {
|
||||
this._speculativeConnect();
|
||||
}
|
||||
},
|
||||
|
||||
get selectedIndex() {
|
||||
for (let i = 0; i < this._table.children.length; i++) {
|
||||
let row = this._table.children[i];
|
||||
if (row.classList.contains("selected")) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
},
|
||||
|
||||
set selectedIndex(idx) {
|
||||
// Update the table's rows, and the input when there is a selection.
|
||||
this._table.removeAttribute("aria-activedescendant");
|
||||
for (let i = 0; i < this._table.children.length; i++) {
|
||||
let row = this._table.children[i];
|
||||
if (i == idx) {
|
||||
row.classList.add("selected");
|
||||
row.firstChild.setAttribute("aria-selected", "true");
|
||||
this._table.setAttribute("aria-activedescendant", row.firstChild.id);
|
||||
this.input.value = this.suggestionAtIndex(i);
|
||||
}
|
||||
else {
|
||||
row.classList.remove("selected");
|
||||
row.firstChild.setAttribute("aria-selected", "false");
|
||||
}
|
||||
}
|
||||
|
||||
// Update the input when there is no selection.
|
||||
if (idx < 0) {
|
||||
this.input.value = this._stickyInputValue;
|
||||
}
|
||||
},
|
||||
|
||||
get numSuggestions() {
|
||||
return this._table.children.length;
|
||||
},
|
||||
|
||||
suggestionAtIndex: function (idx) {
|
||||
let row = this._table.children[idx];
|
||||
return row ? row.textContent : null;
|
||||
},
|
||||
|
||||
deleteSuggestionAtIndex: function (idx) {
|
||||
// Only form history suggestions can be deleted.
|
||||
if (this.isFormHistorySuggestionAtIndex(idx)) {
|
||||
let suggestionStr = this.suggestionAtIndex(idx);
|
||||
this._sendMsg("RemoveFormHistoryEntry", suggestionStr);
|
||||
this._table.children[idx].remove();
|
||||
this.selectedIndex = -1;
|
||||
}
|
||||
},
|
||||
|
||||
isFormHistorySuggestionAtIndex: function (idx) {
|
||||
let row = this._table.children[idx];
|
||||
return row && row.classList.contains("formHistory");
|
||||
},
|
||||
|
||||
addInputValueToFormHistory: function () {
|
||||
this._sendMsg("AddFormHistoryEntry", this.input.value);
|
||||
},
|
||||
|
||||
handleEvent: function (event) {
|
||||
this["_on" + event.type[0].toUpperCase() + event.type.substr(1)](event);
|
||||
},
|
||||
|
||||
_onInput: function () {
|
||||
if (this.input.value) {
|
||||
this._getSuggestions();
|
||||
}
|
||||
else {
|
||||
this._stickyInputValue = "";
|
||||
this._hideSuggestions();
|
||||
}
|
||||
this.selectedIndex = -1;
|
||||
},
|
||||
|
||||
_onKeypress: function (event) {
|
||||
let selectedIndexDelta = 0;
|
||||
switch (event.keyCode) {
|
||||
case event.DOM_VK_UP:
|
||||
if (this.numSuggestions) {
|
||||
selectedIndexDelta = -1;
|
||||
}
|
||||
break;
|
||||
case event.DOM_VK_DOWN:
|
||||
if (this.numSuggestions) {
|
||||
selectedIndexDelta = 1;
|
||||
}
|
||||
else {
|
||||
this._getSuggestions();
|
||||
}
|
||||
break;
|
||||
case event.DOM_VK_RIGHT:
|
||||
// Allow normal caret movement until the caret is at the end of the input.
|
||||
if (this.input.selectionStart != this.input.selectionEnd ||
|
||||
this.input.selectionEnd != this.input.value.length) {
|
||||
return;
|
||||
}
|
||||
// else, fall through
|
||||
case event.DOM_VK_RETURN:
|
||||
if (this.selectedIndex >= 0) {
|
||||
this.input.value = this.suggestionAtIndex(this.selectedIndex);
|
||||
}
|
||||
this._stickyInputValue = this.input.value;
|
||||
this._hideSuggestions();
|
||||
break;
|
||||
case event.DOM_VK_DELETE:
|
||||
if (this.selectedIndex >= 0) {
|
||||
this.deleteSuggestionAtIndex(this.selectedIndex);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIndexDelta) {
|
||||
// Update the selection.
|
||||
let newSelectedIndex = this.selectedIndex + selectedIndexDelta;
|
||||
if (newSelectedIndex < -1) {
|
||||
newSelectedIndex = this.numSuggestions - 1;
|
||||
}
|
||||
else if (this.numSuggestions <= newSelectedIndex) {
|
||||
newSelectedIndex = -1;
|
||||
}
|
||||
this.selectedIndex = newSelectedIndex;
|
||||
|
||||
// Prevent the input's caret from moving.
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
_onFocus: function () {
|
||||
this._speculativeConnect();
|
||||
},
|
||||
|
||||
_onBlur: function () {
|
||||
this._hideSuggestions();
|
||||
},
|
||||
|
||||
_onMousemove: function (event) {
|
||||
// It's important to listen for mousemove, not mouseover or mouseenter. The
|
||||
// latter two are triggered when the user is typing and the mouse happens to
|
||||
// be over the suggestions popup.
|
||||
this.selectedIndex = this._indexOfTableRowOrDescendent(event.target);
|
||||
},
|
||||
|
||||
_onMousedown: function (event) {
|
||||
let idx = this._indexOfTableRowOrDescendent(event.target);
|
||||
let suggestion = this.suggestionAtIndex(idx);
|
||||
this._stickyInputValue = suggestion;
|
||||
this.input.value = suggestion;
|
||||
this._hideSuggestions();
|
||||
if (this.onClick) {
|
||||
this.onClick.call(null);
|
||||
}
|
||||
},
|
||||
|
||||
_onContentSearchService: function (event) {
|
||||
let methodName = "_onMsg" + event.detail.type;
|
||||
if (methodName in this) {
|
||||
this[methodName](event.detail.data);
|
||||
}
|
||||
},
|
||||
|
||||
_onMsgSuggestions: function (suggestions) {
|
||||
// Ignore the suggestions if their search string or engine doesn't match
|
||||
// ours. Due to the async nature of message passing, this can easily happen
|
||||
// when the user types quickly.
|
||||
if (this._stickyInputValue != suggestions.searchString ||
|
||||
this.engineName != suggestions.engineName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Empty the table.
|
||||
while (this._table.firstElementChild) {
|
||||
this._table.firstElementChild.remove();
|
||||
}
|
||||
|
||||
// Position and size the table.
|
||||
let { left, bottom } = this.input.getBoundingClientRect();
|
||||
this._table.style.left = (left + window.scrollX) + "px";
|
||||
this._table.style.top = (bottom + window.scrollY) + "px";
|
||||
this._table.style.minWidth = this.input.offsetWidth + "px";
|
||||
this._table.style.maxWidth = (window.innerWidth - left - 40) + "px";
|
||||
|
||||
// Add the suggestions to the table.
|
||||
let searchWords =
|
||||
new Set(suggestions.searchString.trim().toLowerCase().split(/\s+/));
|
||||
for (let i = 0; i < MAX_DISPLAYED_SUGGESTIONS; i++) {
|
||||
let type, idx;
|
||||
if (i < suggestions.formHistory.length) {
|
||||
[type, idx] = ["formHistory", i];
|
||||
}
|
||||
else {
|
||||
let j = i - suggestions.formHistory.length;
|
||||
if (j < suggestions.remote.length) {
|
||||
[type, idx] = ["remote", j];
|
||||
}
|
||||
else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._table.appendChild(this._makeTableRow(type, suggestions[type][idx],
|
||||
i, searchWords));
|
||||
}
|
||||
|
||||
this._table.hidden = false;
|
||||
this.input.setAttribute("aria-expanded", "true");
|
||||
},
|
||||
|
||||
_speculativeConnect: function () {
|
||||
if (this.engineName) {
|
||||
this._sendMsg("SpeculativeConnect", this.engineName);
|
||||
}
|
||||
},
|
||||
|
||||
_makeTableRow: function (type, suggestionStr, currentRow, searchWords) {
|
||||
let row = document.createElementNS(HTML_NS, "tr");
|
||||
row.classList.add("searchSuggestionRow");
|
||||
row.classList.add(type);
|
||||
row.setAttribute("role", "presentation");
|
||||
row.addEventListener("mousemove", this);
|
||||
row.addEventListener("mousedown", this);
|
||||
|
||||
let entry = document.createElementNS(HTML_NS, "td");
|
||||
entry.classList.add("searchSuggestionEntry");
|
||||
entry.setAttribute("role", "option");
|
||||
entry.id = this._idPrefix + SUGGESTION_ID_PREFIX + currentRow;
|
||||
entry.setAttribute("aria-selected", "false");
|
||||
|
||||
let suggestionWords = suggestionStr.trim().toLowerCase().split(/\s+/);
|
||||
for (let i = 0; i < suggestionWords.length; i++) {
|
||||
let word = suggestionWords[i];
|
||||
let wordSpan = document.createElementNS(HTML_NS, "span");
|
||||
if (searchWords.has(word)) {
|
||||
wordSpan.classList.add("typed");
|
||||
}
|
||||
wordSpan.textContent = word;
|
||||
entry.appendChild(wordSpan);
|
||||
if (i < suggestionWords.length - 1) {
|
||||
entry.appendChild(document.createTextNode(" "));
|
||||
}
|
||||
}
|
||||
|
||||
row.appendChild(entry);
|
||||
return row;
|
||||
},
|
||||
|
||||
_getSuggestions: function () {
|
||||
this._stickyInputValue = this.input.value;
|
||||
if (this.engineName) {
|
||||
this._sendMsg("GetSuggestions", {
|
||||
engineName: this.engineName,
|
||||
searchString: this.input.value,
|
||||
remoteTimeout: this.remoteTimeout,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
_hideSuggestions: function () {
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this._table.hidden = true;
|
||||
while (this._table.firstElementChild) {
|
||||
this._table.firstElementChild.remove();
|
||||
}
|
||||
this.selectedIndex = -1;
|
||||
},
|
||||
|
||||
_indexOfTableRowOrDescendent: function (row) {
|
||||
while (row && row.localName != "tr") {
|
||||
row = row.parentNode;
|
||||
}
|
||||
if (!row) {
|
||||
throw new Error("Element is not a row");
|
||||
}
|
||||
return row.rowIndex;
|
||||
},
|
||||
|
||||
_makeTable: function (id) {
|
||||
this._table = document.createElementNS(HTML_NS, "table");
|
||||
this._table.id = id;
|
||||
this._table.hidden = true;
|
||||
this._table.dir = "auto";
|
||||
this._table.classList.add("searchSuggestionTable");
|
||||
this._table.setAttribute("role", "listbox");
|
||||
return this._table;
|
||||
},
|
||||
|
||||
_sendMsg: function (type, data=null) {
|
||||
dispatchEvent(new CustomEvent("ContentSearchClient", {
|
||||
detail: {
|
||||
type: type,
|
||||
data: data,
|
||||
},
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
return SearchSuggestionUIController;
|
||||
})();
|
|
@ -66,6 +66,8 @@ support-files =
|
|||
page_style_sample.html
|
||||
print_postdata.sjs
|
||||
redirect_bug623155.sjs
|
||||
searchSuggestionEngine.sjs
|
||||
searchSuggestionEngine.xml
|
||||
test-mixedcontent-securityerrors.html
|
||||
test_bug435035.html
|
||||
test_bug462673.html
|
||||
|
@ -374,6 +376,10 @@ skip-if = buildapp == 'mulet' || e10s # e10s: Bug 933103 - mochitest's EventUtil
|
|||
[browser_save_video.js]
|
||||
skip-if = buildapp == 'mulet' || e10s # Bug ?????? - test directly manipulates content (event.target)
|
||||
[browser_scope.js]
|
||||
[browser_searchSuggestionUI.js]
|
||||
support-files =
|
||||
searchSuggestionUI.html
|
||||
searchSuggestionUI.js
|
||||
[browser_selectTabAtIndex.js]
|
||||
skip-if = e10s # Bug ?????? - no idea! "Accel+9 selects expected tab - Got 0, expected 9"
|
||||
[browser_star_hsts.js]
|
||||
|
|
|
@ -97,7 +97,9 @@ let gTests = [
|
|||
setup: function () { },
|
||||
run: function () {
|
||||
// Skip this test on Linux.
|
||||
if (navigator.platform.indexOf("Linux") == 0) { return; }
|
||||
if (navigator.platform.indexOf("Linux") == 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
try {
|
||||
let cm = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager);
|
||||
|
@ -372,6 +374,53 @@ let gTests = [
|
|||
}
|
||||
},
|
||||
|
||||
{
|
||||
// See browser_searchSuggestionUI.js for comprehensive content search
|
||||
// suggestion UI tests.
|
||||
desc: "Search suggestion smoke test",
|
||||
setup: function() {},
|
||||
run: function()
|
||||
{
|
||||
return Task.spawn(function* () {
|
||||
// Add a test engine that provides suggestions and switch to it.
|
||||
let engine = yield promiseNewEngine("searchSuggestionEngine.xml");
|
||||
let promise = promiseBrowserAttributes(gBrowser.selectedTab);
|
||||
Services.search.currentEngine = engine;
|
||||
yield promise;
|
||||
|
||||
// Avoid intermittent failures.
|
||||
gBrowser.contentWindow.wrappedJSObject.gSearchSuggestionController.remoteTimeout = 5000;
|
||||
|
||||
// Type an X in the search input.
|
||||
let input = gBrowser.contentDocument.getElementById("searchText");
|
||||
input.focus();
|
||||
EventUtils.synthesizeKey("x", {});
|
||||
|
||||
// Wait for the search suggestions to become visible.
|
||||
let table =
|
||||
gBrowser.contentDocument.getElementById("searchSuggestionTable");
|
||||
let deferred = Promise.defer();
|
||||
let observer = new MutationObserver(() => {
|
||||
if (input.getAttribute("aria-expanded") == "true") {
|
||||
observer.disconnect();
|
||||
ok(!table.hidden, "Search suggestion table unhidden");
|
||||
deferred.resolve();
|
||||
}
|
||||
});
|
||||
observer.observe(input, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-expanded"],
|
||||
});
|
||||
yield deferred.promise;
|
||||
|
||||
// Empty the search input, causing the suggestions to be hidden.
|
||||
EventUtils.synthesizeKey("a", { accelKey: true });
|
||||
EventUtils.synthesizeKey("VK_DELETE", {});
|
||||
ok(table.hidden, "Search suggestion table hidden");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
function test()
|
||||
|
@ -547,3 +596,21 @@ function waitForLoad(cb) {
|
|||
cb();
|
||||
}, true);
|
||||
}
|
||||
|
||||
function promiseNewEngine(basename) {
|
||||
info("Waiting for engine to be added: " + basename);
|
||||
let addDeferred = Promise.defer();
|
||||
let url = getRootDirectory(gTestPath) + basename;
|
||||
Services.search.addEngine(url, Ci.nsISearchEngine.TYPE_MOZSEARCH, "", false, {
|
||||
onSuccess: function (engine) {
|
||||
info("Search engine added: " + basename);
|
||||
registerCleanupFunction(() => Services.search.removeEngine(engine));
|
||||
addDeferred.resolve(engine);
|
||||
},
|
||||
onError: function (errCode) {
|
||||
ok(false, "addEngine failed with error code " + errCode);
|
||||
addDeferred.reject();
|
||||
},
|
||||
});
|
||||
return addDeferred.promise;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,305 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
const TEST_PAGE_BASENAME = "searchSuggestionUI.html";
|
||||
const TEST_CONTENT_SCRIPT_BASENAME = "searchSuggestionUI.js";
|
||||
const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml";
|
||||
|
||||
const TEST_MSG = "SearchSuggestionUIControllerTest";
|
||||
|
||||
add_task(function* emptyInput() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
state = yield msg("key", "VK_BACK_SPACE");
|
||||
checkState(state, "", [], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* blur() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
state = yield msg("blur");
|
||||
checkState(state, "x", [], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* arrowKeys() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
// Cycle down the suggestions starting from no selection.
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
// Cycle up starting from no selection.
|
||||
state = yield msg("key", "VK_UP");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
state = yield msg("key", "VK_UP");
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
|
||||
|
||||
state = yield msg("key", "VK_UP");
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
// The right arrow and return key function the same.
|
||||
function rightArrowOrReturn(keyName) {
|
||||
return function* rightArrowOrReturnTest() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
|
||||
|
||||
// This should make the xfoo suggestion sticky. To make sure it sticks,
|
||||
// trigger suggestions again and cycle through them by pressing Down until
|
||||
// nothing is selected again.
|
||||
state = yield msg("key", keyName);
|
||||
checkState(state, "xfoo", [], -1);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
|
||||
checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoofoo", ["xfoofoo", "xfoobar"], 0);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoobar", ["xfoofoo", "xfoobar"], 1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
|
||||
|
||||
yield msg("reset");
|
||||
};
|
||||
}
|
||||
|
||||
add_task(rightArrowOrReturn("VK_RIGHT"));
|
||||
add_task(rightArrowOrReturn("VK_RETURN"));
|
||||
|
||||
add_task(function* mouse() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
// Mouse over the first suggestion.
|
||||
state = yield msg("mousemove", 0);
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
|
||||
|
||||
// Mouse over the second suggestion.
|
||||
state = yield msg("mousemove", 1);
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
// Click the second suggestion. This should make it sticky. To make sure it
|
||||
// sticks, trigger suggestions again and cycle through them by pressing Down
|
||||
// until nothing is selected again.
|
||||
state = yield msg("mousedown", 1);
|
||||
checkState(state, "xbar", [], -1);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
|
||||
checkState(state, "xbar", ["xbarfoo", "xbarbar"], -1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbarfoo", ["xbarfoo", "xbarbar"], 0);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbarbar", ["xbarfoo", "xbarbar"], 1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbar", ["xbarfoo", "xbarbar"], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* formHistory() {
|
||||
yield setUp();
|
||||
|
||||
// Type an X and add it to form history.
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
yield msg("addInputValueToFormHistory");
|
||||
|
||||
// Wait for Satchel to say it's been added to form history.
|
||||
let deferred = Promise.defer();
|
||||
Services.obs.addObserver(function onAdd(subj, topic, data) {
|
||||
if (data == "formhistory-add") {
|
||||
executeSoon(() => deferred.resolve());
|
||||
}
|
||||
}, "satchel-storage-changed", false);
|
||||
yield deferred.promise;
|
||||
|
||||
// Reset the input.
|
||||
state = yield msg("reset");
|
||||
checkState(state, "", [], -1);
|
||||
|
||||
// Type an X again. The form history entry should appear.
|
||||
state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
|
||||
-1);
|
||||
|
||||
// Select the form history entry and delete it.
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "x", [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
|
||||
0);
|
||||
|
||||
state = yield msg("key", "VK_DELETE");
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
// Wait for Satchel.
|
||||
deferred = Promise.defer();
|
||||
Services.obs.addObserver(function onAdd(subj, topic, data) {
|
||||
if (data == "formhistory-remove") {
|
||||
executeSoon(() => deferred.resolve());
|
||||
}
|
||||
}, "satchel-storage-changed", false);
|
||||
yield deferred.promise;
|
||||
|
||||
// Reset the input.
|
||||
state = yield msg("reset");
|
||||
checkState(state, "", [], -1);
|
||||
|
||||
// Type an X again. The form history entry should still be gone.
|
||||
state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
|
||||
let gDidInitialSetUp = false;
|
||||
|
||||
function setUp() {
|
||||
return Task.spawn(function* () {
|
||||
if (!gDidInitialSetUp) {
|
||||
yield promiseNewEngine(TEST_ENGINE_BASENAME);
|
||||
yield promiseTab();
|
||||
gDidInitialSetUp = true;
|
||||
}
|
||||
yield msg("focus");
|
||||
});
|
||||
}
|
||||
|
||||
function msg(type, data=null) {
|
||||
gMsgMan.sendAsyncMessage(TEST_MSG, {
|
||||
type: type,
|
||||
data: data,
|
||||
});
|
||||
let deferred = Promise.defer();
|
||||
gMsgMan.addMessageListener(TEST_MSG, function onMsg(msg) {
|
||||
gMsgMan.removeMessageListener(TEST_MSG, onMsg);
|
||||
deferred.resolve(msg.data);
|
||||
});
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function checkState(actualState, expectedInputVal, expectedSuggestions,
|
||||
expectedSelectedIdx) {
|
||||
expectedSuggestions = expectedSuggestions.map(sugg => {
|
||||
return typeof(sugg) == "object" ? sugg : {
|
||||
str: sugg,
|
||||
type: "remote",
|
||||
};
|
||||
});
|
||||
|
||||
let expectedState = {
|
||||
selectedIndex: expectedSelectedIdx,
|
||||
numSuggestions: expectedSuggestions.length,
|
||||
suggestionAtIndex: expectedSuggestions.map(s => s.str),
|
||||
isFormHistorySuggestionAtIndex:
|
||||
expectedSuggestions.map(s => s.type == "formHistory"),
|
||||
|
||||
tableHidden: expectedSuggestions.length == 0,
|
||||
tableChildrenLength: expectedSuggestions.length,
|
||||
tableChildren: expectedSuggestions.map((s, i) => {
|
||||
let expectedClasses = new Set([s.type]);
|
||||
if (i == expectedSelectedIdx) {
|
||||
expectedClasses.add("selected");
|
||||
}
|
||||
return {
|
||||
textContent: s.str,
|
||||
classes: expectedClasses,
|
||||
};
|
||||
}),
|
||||
|
||||
inputValue: expectedInputVal,
|
||||
ariaExpanded: expectedSuggestions.length == 0 ? "false" : "true",
|
||||
};
|
||||
|
||||
SimpleTest.isDeeply(actualState, expectedState, "State");
|
||||
}
|
||||
|
||||
var gMsgMan;
|
||||
|
||||
function promiseTab() {
|
||||
let deferred = Promise.defer();
|
||||
let tab = gBrowser.addTab();
|
||||
registerCleanupFunction(() => gBrowser.removeTab(tab));
|
||||
gBrowser.selectedTab = tab;
|
||||
let pageURL = getRootDirectory(gTestPath) + TEST_PAGE_BASENAME;
|
||||
tab.linkedBrowser.addEventListener("load", function onLoad(event) {
|
||||
tab.linkedBrowser.removeEventListener("load", onLoad, true);
|
||||
gMsgMan = tab.linkedBrowser.messageManager;
|
||||
gMsgMan.sendAsyncMessage("ContentSearch", {
|
||||
type: "AddToWhitelist",
|
||||
data: [pageURL],
|
||||
});
|
||||
promiseMsg("ContentSearch", "AddToWhitelistAck", gMsgMan).then(() => {
|
||||
let jsURL = getRootDirectory(gTestPath) + TEST_CONTENT_SCRIPT_BASENAME;
|
||||
gMsgMan.loadFrameScript(jsURL, false);
|
||||
deferred.resolve();
|
||||
});
|
||||
}, true, true);
|
||||
openUILinkIn(pageURL, "current");
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function promiseMsg(name, type, msgMan) {
|
||||
let deferred = Promise.defer();
|
||||
info("Waiting for " + name + " message " + type + "...");
|
||||
msgMan.addMessageListener(name, function onMsg(msg) {
|
||||
info("Received " + name + " message " + msg.data.type + "\n");
|
||||
if (msg.data.type == type) {
|
||||
msgMan.removeMessageListener(name, onMsg);
|
||||
deferred.resolve(msg);
|
||||
}
|
||||
});
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
function promiseNewEngine(basename) {
|
||||
info("Waiting for engine to be added: " + basename);
|
||||
let addDeferred = Promise.defer();
|
||||
let url = getRootDirectory(gTestPath) + basename;
|
||||
Services.search.addEngine(url, Ci.nsISearchEngine.TYPE_MOZSEARCH, "", false, {
|
||||
onSuccess: function (engine) {
|
||||
info("Search engine added: " + basename);
|
||||
registerCleanupFunction(() => Services.search.removeEngine(engine));
|
||||
addDeferred.resolve(engine);
|
||||
},
|
||||
onError: function (errCode) {
|
||||
ok(false, "addEngine failed with error code " + errCode);
|
||||
addDeferred.reject();
|
||||
},
|
||||
});
|
||||
return addDeferred.promise;
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
function handleRequest(req, resp) {
|
||||
let suffixes = ["foo", "bar"];
|
||||
let data = [req.queryString, suffixes.map(s => req.queryString + s)];
|
||||
resp.setHeader("Content-Type", "application/json", false);
|
||||
resp.write(JSON.stringify(data));
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Any copyright is dedicated to the Public Domain.
|
||||
- http://creativecommons.org/publicdomain/zero/1.0/ -->
|
||||
|
||||
<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
|
||||
<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName>
|
||||
<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/base/content/test/general/searchSuggestionEngine.sjs?{searchTerms}"/>
|
||||
<Url type="text/html" method="GET" template="http://browser-searchSuggestionEngine.com/searchSuggestionEngine" rel="searchform"/>
|
||||
</SearchPlugin>
|
|
@ -0,0 +1,20 @@
|
|||
<!DOCTYPE html>
|
||||
<!-- Any copyright is dedicated to the Public Domain.
|
||||
- http://creativecommons.org/publicdomain/zero/1.0/ -->
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script type="application/javascript;version=1.8"
|
||||
src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js">
|
||||
</script>
|
||||
<script type="application/javascript;version=1.8"
|
||||
src="chrome://browser/content/searchSuggestionUI.js">
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<input>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,138 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
(function () {
|
||||
|
||||
const TEST_MSG = "SearchSuggestionUIControllerTest";
|
||||
const ENGINE_NAME = "browser_searchSuggestionEngine searchSuggestionEngine.xml";
|
||||
|
||||
let input = content.document.querySelector("input");
|
||||
let gController =
|
||||
new content.SearchSuggestionUIController(input, input.parentNode);
|
||||
gController.engineName = ENGINE_NAME;
|
||||
gController.remoteTimeout = 5000;
|
||||
|
||||
addMessageListener(TEST_MSG, msg => {
|
||||
messageHandlers[msg.data.type](msg.data.data);
|
||||
});
|
||||
|
||||
let messageHandlers = {
|
||||
|
||||
key: function (arg) {
|
||||
let keyName = typeof(arg) == "string" ? arg : arg.key;
|
||||
content.synthesizeKey(keyName, {});
|
||||
let wait = arg.waitForSuggestions ? waitForSuggestions : cb => cb();
|
||||
wait(ack);
|
||||
},
|
||||
|
||||
focus: function () {
|
||||
gController.input.focus();
|
||||
ack();
|
||||
},
|
||||
|
||||
blur: function () {
|
||||
gController.input.blur();
|
||||
ack();
|
||||
},
|
||||
|
||||
mousemove: function (suggestionIdx) {
|
||||
// Copied from widget/tests/test_panel_mouse_coords.xul and
|
||||
// browser/base/content/test/newtab/head.js
|
||||
let row = gController._table.children[suggestionIdx];
|
||||
let rect = row.getBoundingClientRect();
|
||||
let left = content.mozInnerScreenX + rect.left;
|
||||
let x = left + rect.width / 2;
|
||||
let y = content.mozInnerScreenY + rect.top + rect.height / 2;
|
||||
|
||||
let utils = content.SpecialPowers.getDOMWindowUtils(content);
|
||||
let scale = utils.screenPixelsPerCSSPixel;
|
||||
|
||||
let widgetToolkit = content.SpecialPowers.
|
||||
Cc["@mozilla.org/xre/app-info;1"].
|
||||
getService(content.SpecialPowers.Ci.nsIXULRuntime).
|
||||
widgetToolkit;
|
||||
let nativeMsg = widgetToolkit == "cocoa" ? 5 : // NSMouseMoved
|
||||
widgetToolkit == "windows" ? 1 : // MOUSEEVENTF_MOVE
|
||||
3; // GDK_MOTION_NOTIFY
|
||||
|
||||
row.addEventListener("mousemove", function onMove() {
|
||||
row.removeEventListener("mousemove", onMove);
|
||||
ack();
|
||||
});
|
||||
utils.sendNativeMouseEvent(x * scale, y * scale, nativeMsg, 0, null);
|
||||
},
|
||||
|
||||
mousedown: function (suggestionIdx) {
|
||||
gController.onClick = () => {
|
||||
gController.onClick = null;
|
||||
ack();
|
||||
};
|
||||
let row = gController._table.children[suggestionIdx];
|
||||
content.sendMouseEvent({ type: "mousedown" }, row);
|
||||
},
|
||||
|
||||
addInputValueToFormHistory: function () {
|
||||
gController.addInputValueToFormHistory();
|
||||
ack();
|
||||
},
|
||||
|
||||
reset: function () {
|
||||
// Reset both the input and suggestions by select all + delete.
|
||||
gController.input.focus();
|
||||
content.synthesizeKey("a", { accelKey: true });
|
||||
content.synthesizeKey("VK_DELETE", {});
|
||||
ack();
|
||||
},
|
||||
};
|
||||
|
||||
function ack() {
|
||||
sendAsyncMessage(TEST_MSG, currentState());
|
||||
}
|
||||
|
||||
function waitForSuggestions(cb) {
|
||||
let observer = new content.MutationObserver(() => {
|
||||
if (gController.input.getAttribute("aria-expanded") == "true") {
|
||||
observer.disconnect();
|
||||
cb();
|
||||
}
|
||||
});
|
||||
observer.observe(gController.input, {
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-expanded"],
|
||||
});
|
||||
}
|
||||
|
||||
function currentState() {
|
||||
let state = {
|
||||
selectedIndex: gController.selectedIndex,
|
||||
numSuggestions: gController.numSuggestions,
|
||||
suggestionAtIndex: [],
|
||||
isFormHistorySuggestionAtIndex: [],
|
||||
|
||||
tableHidden: gController._table.hidden,
|
||||
tableChildrenLength: gController._table.children.length,
|
||||
tableChildren: [],
|
||||
|
||||
inputValue: gController.input.value,
|
||||
ariaExpanded: gController.input.getAttribute("aria-expanded"),
|
||||
};
|
||||
|
||||
for (let i = 0; i < gController.numSuggestions; i++) {
|
||||
state.suggestionAtIndex.push(gController.suggestionAtIndex(i));
|
||||
state.isFormHistorySuggestionAtIndex.push(
|
||||
gController.isFormHistorySuggestionAtIndex(i));
|
||||
}
|
||||
|
||||
for (let child of gController._table.children) {
|
||||
state.tableChildren.push({
|
||||
textContent: child.textContent,
|
||||
classes: new Set(child.className.split(/\s+/)),
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
})();
|
|
@ -118,6 +118,8 @@ browser.jar:
|
|||
* content/browser/sanitize.xul (content/sanitize.xul)
|
||||
* content/browser/sanitizeDialog.js (content/sanitizeDialog.js)
|
||||
content/browser/sanitizeDialog.css (content/sanitizeDialog.css)
|
||||
content/browser/searchSuggestionUI.js (content/searchSuggestionUI.js)
|
||||
content/browser/searchSuggestionUI.css (content/searchSuggestionUI.css)
|
||||
content/browser/tabbrowser.css (content/tabbrowser.css)
|
||||
* content/browser/tabbrowser.xml (content/tabbrowser.xml)
|
||||
* content/browser/urlbarBindings.xml (content/urlbarBindings.xml)
|
||||
|
|
Загрузка…
Ссылка в новой задаче