Bug 612453 - Provide search suggestions on Firefox Start Page (about:home) (part 3, searchSuggestionUI and about:home). r=MattN

This commit is contained in:
Drew Willcoxon 2014-08-01 12:00:47 -07:00
Родитель f5a7cdd231
Коммит 94376cba8e
12 изменённых файлов: 1001 добавлений и 2 удалений

Просмотреть файл

@ -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)