src/mobile.startup/search/SearchOverlay.js
const
mfExtend = require( '../mfExtend' ),
Overlay = require( '../Overlay' ),
util = require( '../util' ),
searchHeader = require( './searchHeader' ),
SearchResultsView = require( './SearchResultsView' ),
WatchstarPageList = require( '../watchstar/WatchstarPageList' ),
SEARCH_DELAY = 300,
SEARCH_SPINNER_DELAY = 2000;
/**
* Overlay displaying search results
*
* @class SearchOverlay
* @memberof module:mobile.startup/search
* @extends Overlay
* @uses SearchGateway
* @uses IconButton
*
* @param {Object} params Configuration options
* @param {string} params.placeholderMsg Search input placeholder text.
* @param {string} [params.action] of form defaults to the value of wgScript
* @param {string} [params.defaultSearchPage] The default search page e.g. Special:Search
* @param {SearchGateway} [params.gateway]
*/
function SearchOverlay( params ) {
const header = searchHeader(
params.placeholderMsg,
params.action || mw.config.get( 'wgScript' ),
( query ) => this.performSearch( query ),
params.defaultSearchPage || '',
params.autocapitalize
),
options = util.extend( true, {
headerChrome: true,
isBorderBox: false,
className: 'overlay search-overlay',
headers: [ header ],
events: {
'click .search-content': 'onClickSearchContent',
'click .overlay-content': 'onClickOverlayContent',
'click .overlay-content > div': function ( ev ) {
ev.stopPropagation();
},
'touchstart .results': 'hideKeyboardOnScroll',
'mousedown .results': 'hideKeyboardOnScroll',
'click .results a': 'onClickResult'
}
},
params );
this.header = header;
Overlay.call( this, options );
this.api = options.api;
// eslint-disable-next-line new-cap
this.gateway = options.gateway || new options.gatewayClass( this.api );
this.router = options.router;
this.currentSearchId = null;
}
mfExtend( SearchOverlay, Overlay, {
/**
* Initialize 'search within pages' functionality
*
* @memberof SearchOverlay
* @instance
*/
onClickSearchContent() {
const
$form = this.$el.find( 'form' ),
$el = $form[0].parentNode;
// Add fulltext input to force fulltext search
this.parseHTML( '<input>' )
.attr( {
type: 'hidden',
name: 'fulltext',
value: 'search'
} )
.appendTo( $form );
// history.back queues a task so might run after this call. Thus we use setTimeout
// http://www.w3.org/TR/2011/WD-html5-20110113/webappapis.html#queue-a-task
setTimeout( () => {
// Firefox doesn't allow submission of a form not in the DOM
// so temporarily re-add it if it's gone.
if ( !$form[0].parentNode ) {
$form.appendTo( $el );
}
$form.trigger( 'submit' );
}, 0 );
},
/**
* Tapping on background only should hide the overlay
*
* @memberof SearchOverlay
* @instance
*/
onClickOverlayContent() {
this.$el.find( '.cancel' ).trigger( 'click' );
},
/**
* Hide the keyboard when scrolling starts (avoid weird situation when
* user taps on an item, the keyboard hides and wrong item is clicked).
*
* @memberof SearchOverlay
* @instance
*/
hideKeyboardOnScroll() {
this.$input.trigger( 'blur' );
},
/**
* Handle the user clicking a result.
*
* @memberof SearchOverlay
* @instance
* @param {jQuery.Event} ev
*/
onClickResult( ev ) {
const
self = this,
$link = this.$el.find( ev.currentTarget );
/**
* Fired when the user clicks a search result
*
* @type {Object}
* @property {jQuery.Object} result The jQuery-wrapped DOM element that
* the user clicked
* @property {number} resultIndex The zero-based index of the
* result in the set of results
* @property {jQuery.Event} originalEvent The original event
*/
// FIXME: ugly hack that removes search from browser history
// when navigating to search results
ev.preventDefault();
this.router.back().then( function () {
// T308288: Appends the current search id as a url param on clickthroughs
if ( this.currentSearchId ) {
const clickUrl = new URL( location.href );
clickUrl.searchParams.set( 'searchToken', this.currentSearchId );
self.router.navigateTo( document.title, {
path: clickUrl.toString(),
useReplaceState: true
} );
this.currentSearchId = null;
}
// Router.navigate does not support changing href.
// FIXME: Needs upstream change T189173
// eslint-disable-next-line no-restricted-properties
window.location.href = $link.attr( 'href' );
} );
},
/**
* @inheritdoc
* @memberof SearchOverlay
* @instance
*/
postRender() {
const self = this,
searchResults = new SearchResultsView( {
searchContentLabel: mw.msg( 'mobile-frontend-search-content' ),
noResultsMsg: mw.msg( 'mobile-frontend-search-no-results' ),
searchContentNoResultsMsg: mw.message( 'mobile-frontend-search-content-no-results' ).parse()
} );
let timer;
this.$el.find( '.overlay-content' ).append( searchResults.$el );
Overlay.prototype.postRender.call( this );
// FIXME: `this.$input` should not be set. Isolate to searchHeader function
this.$input = this.$el.find( this.header ).find( 'input' );
// FIXME: `this.$searchContent` should not be set. Isolate to SearchResultsView class.
this.$searchContent = searchResults.$el.hide();
// FIXME: `this.$resultContainer` should not be set. Isolate to SearchResultsView class.
this.$resultContainer = searchResults.$el.find( '.results-list-container' );
// On iOS a touchstart event while the keyboard is open will result in a scroll
// leading to an accidental click (T299846)
// Stopping propagation when the input is focused will prevent scrolling while
// the keyboard is collapsed.
this.$resultContainer[0].addEventListener( 'touchstart', ( ev ) => {
if ( document.activeElement === this.$input[0] ) {
ev.stopPropagation();
}
} );
/**
* Hide the spinner and abort timed spinner shows.
* FIXME: Given this manipulates SearchResultsView this should be moved into that class
*/
function clearSearch() {
self.$spinner.hide();
clearTimeout( timer );
}
// Show a spinner on top of search results
// FIXME: Given this manipulates SearchResultsView this should be moved into that class
this.$spinner = searchResults.$el.find( '.spinner-container' );
this.on( 'search-start', ( searchData ) => {
if ( timer ) {
clearSearch();
}
timer = setTimeout( () => self.$spinner.show(),
SEARCH_SPINNER_DELAY - searchData.delay );
} );
this.on( 'search-results', clearSearch );
},
/**
* Trigger a focus() event on search input in order to
* bring up the virtual keyboard.
*
* @memberof SearchOverlay
* @instance
*/
showKeyboard() {
const len = this.$input.val().length;
this.$input.trigger( 'focus' );
// Cursor to the end of the input
if ( this.$input[0].setSelectionRange ) {
this.$input[0].setSelectionRange( len, len );
}
},
/**
* @inheritdoc
* @memberof SearchOverlay
* @instance
*/
show() {
// Overlay#show defines the actual overlay visibility.
Overlay.prototype.show.apply( this, arguments );
this.showKeyboard();
},
/**
* Perform search and render results inside current view.
* FIXME: Much of the logic for caching and pending queries inside this function should
* actually live in SearchGateway, please move out.
*
* @memberof SearchOverlay
* @instance
* @param {string} query
*/
performSearch( query ) {
const
self = this,
api = this.api,
delay = this.gateway.isCached( query ) ? 0 : SEARCH_DELAY;
// it seems the input event can be fired when virtual keyboard is closed
// (Chrome for Android)
if ( query !== this.lastQuery ) {
if ( self._pendingQuery ) {
self._pendingQuery.abort();
}
clearTimeout( this.timer );
if ( query.length ) {
this.timer = setTimeout( () => {
const xhr = self.gateway.search( query );
self._pendingQuery = xhr.then( function ( data ) {
this.currentSearchId = data.searchId;
// FIXME: Given this manipulates SearchResultsView
// this should be moved into that class
// check if we're getting the rights response in case of out of
// order responses (need to get the current value of the input)
if ( data && data.query === self.$input.val() ) {
self.$el.toggleClass( 'no-results', data.results.length === 0 );
self.$searchContent
.show()
.find( 'p' )
.hide()
.filter( data.results.length ? '.with-results' : '.without-results' )
.show();
// eslint-disable-next-line no-new
new WatchstarPageList( {
api,
funnel: 'search',
pages: data.results,
el: self.$resultContainer
} );
self.$results = self.$resultContainer.find( 'li' );
}
} ).promise( {
abort() {
xhr.abort();
}
} );
}, delay );
} else {
self.resetSearch();
}
this.lastQuery = query;
}
},
/**
* Clear results
*
* @private
*/
resetSearch() {
this.$el.find( '.overlay-content' ).children().hide();
}
} );
module.exports = SearchOverlay;