Fix Bug 1508388 - Search hand-off (#4599)

This commit is contained in:
ricky rosario 2018-12-21 19:33:38 -05:00 коммит произвёл GitHub
Родитель a45a540a14
Коммит 4a5d8c8475
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 396 добавлений и 21 удалений

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

@ -44,6 +44,9 @@ for (const type of [
"DISCOVERY_STREAM_LAYOUT_UPDATE",
"DOWNLOAD_CHANGED",
"FILL_SEARCH_TERM",
"FOCUS_SEARCH",
"HANDOFF_SEARCH_TO_AWESOMEBAR",
"HIDE_SEARCH",
"INIT",
"MIGRATION_CANCEL",
"MIGRATION_COMPLETED",
@ -93,6 +96,7 @@ for (const type of [
"SET_PREF",
"SHOW_DOWNLOAD_FILE",
"SHOW_FIREFOX_ACCOUNTS",
"SHOW_SEARCH",
"SKIPPED_SIGNIN",
"SNIPPETS_BLOCKLIST_CLEARED",
"SNIPPETS_BLOCKLIST_UPDATED",

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

@ -53,6 +53,12 @@ const INITIAL_STATE = {
config: {enabled: false, layout_endpoint: ""},
layout: [],
},
Search: {
// Pretend the search box is focused after handing off to AwesomeBar.
focus: false,
// Hide the search box after handing off to AwesomeBar and user starts typing.
hide: false,
},
};
function App(prevState = INITIAL_STATE.App, action) {
@ -448,10 +454,40 @@ function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) {
}
}
function Search(prevState = INITIAL_STATE.Search, action) {
switch (action.type) {
case at.HIDE_SEARCH:
return Object.assign({...prevState, hide: true});
case at.FOCUS_SEARCH:
return Object.assign({...prevState, focus: true});
case at.SHOW_SEARCH:
return Object.assign({...prevState, hide: false, focus: false});
default:
return prevState;
}
}
this.INITIAL_STATE = INITIAL_STATE;
this.TOP_SITES_DEFAULT_ROWS = TOP_SITES_DEFAULT_ROWS;
this.TOP_SITES_MAX_SITES_PER_ROW = TOP_SITES_MAX_SITES_PER_ROW;
this.reducers = {TopSites, App, ASRouter, Snippets, Prefs, Dialog, Sections, Pocket, DiscoveryStream};
this.reducers = {
TopSites,
App,
ASRouter,
Snippets,
Prefs,
Dialog,
Sections,
Pocket,
DiscoveryStream,
Search,
};
const EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE", "insertPinned", "TOP_SITES_DEFAULT_ROWS", "TOP_SITES_MAX_SITES_PER_ROW"];
const EXPORTED_SYMBOLS = [
"reducers",
"INITIAL_STATE",
"insertPinned",
"TOP_SITES_DEFAULT_ROWS",
"TOP_SITES_MAX_SITES_PER_ROW",
];

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

@ -141,6 +141,7 @@ export class BaseContent extends React.PureComponent {
const shouldBeFixedToTop = PrerenderData.arePrefsValid(name => prefs[name]);
const noSectionsEnabled = !prefs["feeds.topsites"] && props.Sections.filter(section => section.enabled).length === 0;
const isDiscoveryStream = props.DiscoveryStream.config && props.DiscoveryStream.config.enabled;
const searchHandoffEnabled = prefs["improvesearch.handoffToAwesomebar"];
const outerClassName = [
"outer-wrapper",
@ -156,7 +157,7 @@ export class BaseContent extends React.PureComponent {
{prefs.showSearch &&
<div className="non-collapsible-section">
<ErrorBoundary>
<Search showLogo={noSectionsEnabled} />
<Search showLogo={noSectionsEnabled} handoffEnabled={searchHandoffEnabled} {...props.Search} />
</ErrorBoundary>
</div>
}
@ -176,4 +177,10 @@ export class BaseContent extends React.PureComponent {
}
}
export const Base = connect(state => ({App: state.App, Prefs: state.Prefs, Sections: state.Sections, DiscoveryStream: state.DiscoveryStream}))(_Base);
export const Base = connect(state => ({
App: state.App,
Prefs: state.Prefs,
Sections: state.Sections,
DiscoveryStream: state.DiscoveryStream,
Search: state.Search,
}))(_Base);

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

@ -1,8 +1,8 @@
/* globals ContentSearchUIController */
"use strict";
import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm";
import {FormattedMessage, injectIntl} from "react-intl";
import {actionCreators as ac} from "common/Actions.jsm";
import {connect} from "react-redux";
import {IS_NEWTAB} from "content-src/lib/constants";
import React from "react";
@ -10,7 +10,8 @@ import React from "react";
export class _Search extends React.PureComponent {
constructor(props) {
super(props);
this.onClick = this.onClick.bind(this);
this.onSearchClick = this.onSearchClick.bind(this);
this.onSearchHandoffClick = this.onSearchHandoffClick.bind(this);
this.onInputMount = this.onInputMount.bind(this);
}
@ -21,10 +22,26 @@ export class _Search extends React.PureComponent {
}
}
onClick(event) {
onSearchClick(event) {
window.gContentSearchController.search(event);
}
onSearchHandoffClick(event) {
// When search hand-off is enabled, we render a big button that is styled to
// look like a search textbox. If the button is clicked with the mouse, we style
// the button as if it was a focused search box and show a fake cursor but
// really focus the awesomebar without the focus styles.
// If the button is clicked from the keyboard, we focus the awesomebar normally.
// This is to minimize confusion with users navigating with the keyboard and
// users using assistive technologoy.
const isKeyboardClick = event.clientX === 0 && event.clientY === 0;
const hiddenFocus = !isKeyboardClick;
this.props.dispatch(ac.OnlyToMain({type: at.HANDOFF_SEARCH_TO_AWESOMEBAR, data: {hiddenFocus}}));
this.props.dispatch({type: at.FOCUS_SEARCH});
// TODO: Send a telemetry ping. BUG 1514732
}
componentWillUnmount() {
delete window.gContentSearchController;
}
@ -63,13 +80,20 @@ export class _Search extends React.PureComponent {
* in order to execute searches in various tests
*/
render() {
return (<div className="search-wrapper">
const wrapperClassName = [
"search-wrapper",
this.props.hide && "search-hidden",
this.props.focus && "search-active",
].filter(v => v).join(" ");
return (<div className={wrapperClassName}>
{this.props.showLogo &&
<div className="logo-and-wordmark">
<div className="logo" />
<div className="wordmark" />
</div>
}
{!this.props.handoffEnabled &&
<div className="search-inner-wrapper">
<label htmlFor="newtab-search-text" className="search-label">
<span className="sr-only"><FormattedMessage id="search_web_placeholder" /></span>
@ -84,11 +108,32 @@ export class _Search extends React.PureComponent {
<button
id="searchSubmit"
className="search-button"
onClick={this.onClick}
onClick={this.onSearchClick}
title={this.props.intl.formatMessage({id: "search_button"})}>
<span className="sr-only"><FormattedMessage id="search_button" /></span>
</button>
</div>
}
{this.props.handoffEnabled &&
<div className="search-inner-wrapper">
<button
className="search-handoff-button"
onClick={this.onSearchHandoffClick}
title={this.props.intl.formatMessage({id: "search_web_placeholder"})}>
<div className="fake-textbox">{this.props.intl.formatMessage({id: "search_web_placeholder"})}</div>
<div className="fake-caret" />
<div className="fake-button" />
</button>
{/*
This dummy and hidden input below is so we can load ContentSearchUIController.
Why? It sets --newtab-search-icon for us and it isn't trivial to port over.
*/}
<input
type="search"
style={{display: "none"}}
ref={this.onInputMount} />
</div>
}
</div>);
}
}

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

@ -1,11 +1,11 @@
.search-wrapper {
$search-height: 48px;
$search-icon-size: 24px;
$search-icon-padding: 12px;
$search-icon-width: 2 * $search-icon-padding + $search-icon-size -2;
$search-button-width: 48px;
$glyph-forward: url('chrome://browser/skin/forward.svg');
$search-height: 48px;
$search-icon-size: 24px;
$search-icon-padding: 12px;
$search-icon-width: 2 * $search-icon-padding + $search-icon-size -2;
$search-button-width: 48px;
$glyph-forward: url('chrome://browser/skin/forward.svg');
.search-wrapper {
padding: 34px 0 64px;
@media (max-height: 700px) {
@ -137,6 +137,85 @@
}
}
.search-handoff-button {
background: var(--newtab-textbox-background-color) var(--newtab-search-icon) $search-icon-padding center no-repeat;
background-size: $search-icon-size;
border: solid 1px var(--newtab-search-border-color);
border-radius: 3px;
box-shadow: $shadow-secondary, 0 0 0 1px $black-15;
cursor: text;
font-size: 15px;
padding: 0;
padding-inline-end: 48px;
padding-inline-start: 46px;
opacity: 1;
transition: opacity 500ms;
width: 100%;
&:dir(rtl) {
background-position-x: right $search-icon-padding;
}
&:hover {
box-shadow: $shadow-secondary, 0 0 0 1px $black-25;
}
&:focus,
.search-active & {
border: $input-border-active;
box-shadow: var(--newtab-textbox-focus-boxshadow);
}
.search-hidden & {
opacity: 0;
visibility: hidden;
}
.fake-textbox {
opacity: 0.54;
text-align: left;
}
.fake-caret {
animation: caret-animation 1.3s steps(5, start) infinite;
background: var(--newtab-text-primary-color);
display: none;
inset-inline-start: 47px;
height: 17px;
position: absolute;
top: 16px;
width: 1px;
@keyframes caret-animation {
to {
visibility: hidden;
}
}
.search-active & {
display: block;
}
}
.fake-button {
background: $glyph-forward no-repeat center center;
background-size: 16px 16px;
border: 0;
border-radius: 0 $border-radius $border-radius 0;
-moz-context-properties: fill;
fill: var(--newtab-search-icon-color);
height: 100%;
inset-inline-end: 0;
position: absolute;
top: 1px;
width: $search-button-width;
&:dir(rtl) {
transform: scaleX(-1);
}
}
}
@media (min-height: 701px) {
.fixed-search {
main {
@ -172,6 +251,19 @@
}
}
}
.search-handoff-button {
background-position-x: $search-icon-padding;
background-size: $search-icon-size;
&:dir(rtl) {
background-position-x: right $search-icon-padding;
}
.fake-caret {
top: 10px;
}
}
}
}

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

@ -193,6 +193,10 @@ const PREFS_CONFIG = new Map([
title: "A comma-delimited list of search shortcuts that have previously been pinned",
value: "",
}],
["improvesearch.handoffToAwesomebar", {
title: "Should the search box handoff to the Awesomebar?",
value: true,
}],
["asrouter.devtoolsEnabled", {
title: "Are the asrouter devtools enabled?",
value: false,

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

@ -283,6 +283,38 @@ class PlacesFeed {
_target.browser.ownerGlobal.gURLBar.search(`${data.label} `);
}
handoffSearchToAwesomebar({_target, data, meta}) {
const urlBar = _target.browser.ownerGlobal.gURLBar;
if (!data.hiddenFocus) {
// Do a normal focus of awesomebar and reset the in content search (remove fake focus styles).
urlBar.focus();
this.store.dispatch(ac.OnlyToOneContent({type: at.SHOW_SEARCH}, meta.fromTarget));
return;
}
// Focus the awesomebar without the style changes.
urlBar.hiddenFocus();
const onKeydown = () => {
// Once the user starts typing, we want to hide the in content search box
// and show the focus styles on the awesomebar.
this.store.dispatch(ac.OnlyToOneContent({type: at.HIDE_SEARCH}, meta.fromTarget));
urlBar.removeHiddenFocus();
urlBar.removeEventListener("keydown", onKeydown);
};
const onDone = () => {
// When done, let's cleanup everything.
this.store.dispatch(ac.OnlyToOneContent({type: at.SHOW_SEARCH}, meta.fromTarget));
urlBar.removeHiddenFocus();
urlBar.removeEventListener("keydown", onKeydown);
urlBar.removeEventListener("mousedown", onDone);
urlBar.removeEventListener("blur", onDone);
};
urlBar.addEventListener("keydown", onKeydown);
urlBar.addEventListener("mousedown", onDone);
urlBar.addEventListener("blur", onDone);
}
onAction(action) {
switch (action.type) {
case at.INIT:
@ -323,6 +355,9 @@ class PlacesFeed {
case at.FILL_SEARCH_TERM:
this.fillSearchTopSiteTerm(action);
break;
case at.HANDOFF_SEARCH_TO_AWESOMEBAR:
this.handoffSearchToAwesomebar(action);
break;
case at.OPEN_LINK: {
this.openLink(action);
break;

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

@ -1,9 +1,24 @@
"use strict";
test_newtab(function test_render_search() {
let search = content.document.getElementById("newtab-search-text");
ok(search, "Got the search box");
isnot(search.placeholder, "search_web_placeholder", "Search box is localized");
test_newtab({
async before({pushPrefs}) {
await pushPrefs(["browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", false]);
},
test: function test_render_search() {
let search = content.document.getElementById("newtab-search-text");
ok(search, "Got the search box");
isnot(search.placeholder, "search_web_placeholder", "Search box is localized");
},
});
test_newtab({
async before({pushPrefs}) {
await pushPrefs(["browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", true]);
},
test: function test_render_search_handoff() {
let search = content.document.querySelector(".search-handoff-button");
ok(search, "Got the search handoff button");
},
});
test_newtab(function test_render_topsites() {

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

@ -1,5 +1,5 @@
import {INITIAL_STATE, insertPinned, reducers} from "common/Reducers.jsm";
const {TopSites, App, Snippets, Prefs, Dialog, Sections, Pocket, DiscoveryStream} = reducers;
const {TopSites, App, Snippets, Prefs, Dialog, Sections, Pocket, DiscoveryStream, Search} = reducers;
import {actionTypes as at} from "common/Actions.jsm";
describe("Reducers", () => {
@ -668,4 +668,22 @@ describe("Reducers", () => {
assert.deepEqual(state.config, {enabled: true});
});
});
describe("Search", () => {
it("should return INITIAL_STATE by default", () => {
assert.equal(Search(undefined, {type: "some_action"}), INITIAL_STATE.Search);
});
it("should set hide to true on HIDE_SEARCH", () => {
const nextState = Search(undefined, {type: "HIDE_SEARCH"});
assert.propertyVal(nextState, "hide", true);
});
it("should set focus to true on FOCUS_SEARCH", () => {
const nextState = Search(undefined, {type: "FOCUS_SEARCH"});
assert.propertyVal(nextState, "focus", true);
});
it("should set focus and hide to false on SHOW_SEARCH", () => {
const nextState = Search(undefined, {type: "SHOW_SEARCH"});
assert.propertyVal(nextState, "focus", false);
assert.propertyVal(nextState, "hide", false);
});
});
});

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

@ -73,4 +73,34 @@ describe("<Search>", () => {
assert.isUserEventAction(action);
assert.propertyVal(action.data, "event", "SEARCH");
});
describe("Search Hand-off", () => {
it("should render a Search element when hand-off is enabled", () => {
const wrapper = shallowWithIntl(<Search {...DEFAULT_PROPS} handoffEnabled={true} />);
assert.ok(wrapper.exists());
assert.equal(wrapper.find(".search-handoff-button").length, 1);
});
it("should hand-off search when button is clicked with mouse", () => {
const dispatch = sinon.spy();
const wrapper = shallowWithIntl(<Search {...DEFAULT_PROPS} handoffEnabled={true} dispatch={dispatch} />);
wrapper.instance().onSearchHandoffClick({clientX: 101, clientY: 102});
assert.calledWith(dispatch, {
data: {hiddenFocus: true},
meta: {from: "ActivityStream:Content", skipLocal: true, to: "ActivityStream:Main"},
type: "HANDOFF_SEARCH_TO_AWESOMEBAR",
});
assert.calledWith(dispatch, {type: "FOCUS_SEARCH"});
});
it("should hand-off search when button is clicked with keyboard", () => {
const dispatch = sinon.spy();
const wrapper = shallowWithIntl(<Search {...DEFAULT_PROPS} handoffEnabled={true} dispatch={dispatch} />);
wrapper.instance().onSearchHandoffClick({clientX: 0, clientY: 0});
assert.calledWith(dispatch, {
data: {hiddenFocus: false},
meta: {from: "ActivityStream:Content", skipLocal: true, to: "ActivityStream:Main"},
type: "HANDOFF_SEARCH_TO_AWESOMEBAR",
});
assert.calledWith(dispatch, {type: "FOCUS_SEARCH"});
});
});
});

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

@ -306,6 +306,95 @@ describe("PlacesFeed", () => {
await feed.saveToPocket(action.data.site, action._target.browser);
assert.notCalled(feed.store.dispatch);
});
it("should call handoffSearchToAwesomebar on HANDOFF_SEARCH_TO_AWESOMEBAR", () => {
const action = {
type: at.HANDOFF_SEARCH_TO_AWESOMEBAR,
data: {hiddenFocus: false},
meta: {fromTarget: {}},
_target: {browser: {ownerGlobal: {gURLBar: {focus: () => {}}}}},
};
sinon.stub(feed, "handoffSearchToAwesomebar");
feed.onAction(action);
assert.calledWith(feed.handoffSearchToAwesomebar, action);
});
});
describe("handoffSearchToAwesomebar", () => {
let fakeUrlBar;
let listeners;
beforeEach(() => {
fakeUrlBar = {
focus: sinon.spy(),
hiddenFocus: sinon.spy(),
removeHiddenFocus: sinon.spy(),
addEventListener: (ev, cb) => {
listeners[ev] = cb;
},
removeEventListener: sinon.spy(),
};
listeners = {};
});
it("should properly handle hiddenFocus=false", () => {
feed.handoffSearchToAwesomebar({
_target: {browser: {ownerGlobal: {gURLBar: fakeUrlBar}}},
data: {hiddenFocus: false},
meta: {fromTarget: {}},
});
assert.calledOnce(fakeUrlBar.focus);
assert.notCalled(fakeUrlBar.hiddenFocus);
assert.calledOnce(feed.store.dispatch);
assert.calledWith(feed.store.dispatch, {
meta: {
from: "ActivityStream:Main",
skipMain: true,
to: "ActivityStream:Content",
toTarget: {},
},
type: "SHOW_SEARCH",
});
});
it("should properly handle hiddenFocus=true", () => {
feed.handoffSearchToAwesomebar({
_target: {browser: {ownerGlobal: {gURLBar: fakeUrlBar}}},
data: {hiddenFocus: true},
meta: {fromTarget: {}},
});
assert.calledOnce(fakeUrlBar.hiddenFocus);
assert.notCalled(fakeUrlBar.focus);
assert.notCalled(feed.store.dispatch);
// Now call keydown listener.
feed.store.dispatch.resetHistory();
listeners.keydown();
assert.calledOnce(fakeUrlBar.removeHiddenFocus);
assert.calledOnce(feed.store.dispatch);
assert.calledWith(feed.store.dispatch, {
meta: {
from: "ActivityStream:Main",
skipMain: true,
to: "ActivityStream:Content",
toTarget: {},
},
type: "HIDE_SEARCH",
});
// And then call blur listener.
fakeUrlBar.removeHiddenFocus.resetHistory();
feed.store.dispatch.resetHistory();
listeners.blur();
assert.calledOnce(fakeUrlBar.removeHiddenFocus);
assert.calledOnce(feed.store.dispatch);
assert.calledWith(feed.store.dispatch, {
meta: {
from: "ActivityStream:Main",
skipMain: true,
to: "ActivityStream:Content",
toTarget: {},
},
type: "SHOW_SEARCH",
});
});
});
describe("#observe", () => {