2 * Add search suggestions to the search form.
5 // eslint-disable-next-line no-jquery/no-map-util
6 const searchNS = $.map( mw.config.get( 'wgFormattedNamespaces' ), ( nsName, nsID ) => {
7 if ( nsID >= 0 && mw.user.options.get( 'searchNs' + nsID ) ) {
8 // Cast string key to number
14 * Convenience library for making searches for titles that match a string.
15 * Loaded via the `mediawiki.searchSuggest` ResourceLoader library.
18 * mw.loader.using('mediawiki.searchSuggest').then(() => {
19 * var api = new mw.Api();
20 * mw.searchSuggest.request(api, 'Dogs that', ( results ) => {
21 * alert( `Results that match: ${results.join( '\n' );}` );
24 * @namespace mw.searchSuggest
28 * @typedef {Object} mw.searchSuggest~ResponseMetaData
29 * @property {string} type the contents of the X-OpenSearch-Type response header.
30 * @property {string} searchId the contents of the X-Search-ID response header.
31 * @property {string} query
34 * @callback mw.searchSuggest~ResponseFunction
35 * @param {string[]} titles titles of pages that match search
36 * @param {ResponseMetaData} meta meta data relating to search.
39 * Queries the wiki and calls response with the result.
42 * @param {string} query
43 * @param {ResponseFunction} response
44 * @param {string|number} [limit]
45 * @param {string|number|string[]|number[]} [namespace]
46 * @return {jQuery.Deferred}
48 request: function ( api, query, response, limit, namespace ) {
53 namespace: namespace || searchNS,
55 } ).done( ( data, jqXHR ) => {
56 response( data[ 1 ], {
57 type: jqXHR.getResponseHeader( 'X-OpenSearch-Type' ),
58 searchId: jqXHR.getResponseHeader( 'X-Search-ID' ),
67 // Region where the suggestions box will appear directly below
68 // (using the same width). Can be a container element or the input
69 // itself, depending on what suits best in the environment.
70 // For Vector the suggestion box should align with the simpleSearch
71 // container's borders, in other skins it should align with the input
72 // element (not the search form, as that would leave the buttons
73 // vertically between the input and the suggestions).
74 const $searchRegion = $( '#simpleSearch, #searchInput' ).first(),
75 $searchInput = $( '#searchInput' );
76 let previousSearchText = $searchInput.val();
78 function serializeObject( fields ) {
81 for ( let i = 0; i < fields.length; i++ ) {
82 obj[ fields[ i ].name ] = fields[ i ].value;
88 // Compute form data for search suggestions functionality.
89 function getFormData( context ) {
90 if ( !context.formData ) {
91 // Compute common parameters for links' hrefs
92 const $form = context.config.$region.closest( 'form' );
94 let baseHref = $form.attr( 'action' ) || '';
95 baseHref += baseHref.indexOf( '?' ) > -1 ? '&' : '?';
97 const linkParams = serializeObject( $form.serializeArray() );
100 textParam: context.data.$textbox.attr( 'name' ),
101 linkParams: linkParams,
106 return context.formData;
110 * Callback that's run when the user changes the search input text
111 * 'this' is the search input box (jQuery object)
115 function onBeforeUpdate() {
116 const searchText = this.val();
118 if ( searchText && searchText !== previousSearchText ) {
119 mw.track( 'mediawiki.searchSuggest', {
120 action: 'session-start'
123 previousSearchText = searchText;
127 * Defines the location of autocomplete. Typically either
128 * header, which is in the top right of vector (for example)
129 * and content which identifies the main search bar on
130 * Special:Search. Defaults to header for skins that don't set
134 * @param {Object} context
137 function getInputLocation( context ) {
138 return context.config.$region
140 .find( '[data-search-loc]' )
141 .data( 'search-loc' ) || 'header';
145 * Callback that's run when suggestions have been updated either from the cache or the API
146 * 'this' is the search input box (jQuery object)
149 * @param {Object} metadata
151 function onAfterUpdate( metadata ) {
152 const context = this.data( 'suggestionsContext' );
154 mw.track( 'mediawiki.searchSuggest', {
155 action: 'impression-results',
156 numberOfResults: context.config.suggestions.length,
157 resultSetType: metadata.type || 'unknown',
158 searchId: metadata.searchId || null,
159 query: metadata.query,
160 inputLocation: getInputLocation( context )
164 // The function used to render the suggestions.
165 function renderFunction( text, context ) {
166 const formData = getFormData( context ),
167 textboxConfig = context.data.$textbox.data( 'mw-searchsuggest' ) || {};
169 // linkParams object is modified and reused
170 formData.linkParams[ formData.textParam ] = text;
172 // Allow trackers to attach tracking information, such
173 // as wprov, to clicked links.
174 mw.track( 'mediawiki.searchSuggest', {
175 action: 'render-one',
177 index: context.config.suggestions.indexOf( text )
180 // this is the container <div>, jQueryfied
183 // wrap only as link, if the config doesn't disallow it
184 if ( textboxConfig.wrapAsLink !== false ) {
187 .attr( 'href', formData.baseHref + $.param( formData.linkParams ) )
188 .attr( 'title', text )
189 .addClass( 'mw-searchSuggest-link' )
194 // The function used when the user makes a selection
195 function selectFunction( $input, source ) {
196 const context = $input.data( 'suggestionsContext' ),
198 url = $( this ).parent( 'a' ).attr( 'href' );
200 // We want to track a click-result XOR a submit-form action.
201 // If the source was 'click' (or otherwise non-'keyboard'),
202 // track it and then let the rest of the event proceed as normal.
203 // If the source was 'keyboard', and we have a URL
204 // (from the <a> that the result was wrapped in, see renderFunction()),
205 // then also track a click, prevent the regular form submit,
206 // and instead directly navigate to the URL as if it had been clicked.
207 // If the source was 'keyboard', but we have no URL,
208 // then we have to let the regular form submit go through,
209 // so skip the click tracking in that case to avoid duplicate tracking.
210 if ( source === 'keyboard' && url || source !== 'keyboard' ) {
211 mw.track( 'mediawiki.searchSuggest', {
212 action: 'click-result',
213 numberOfResults: context.config.suggestions.length,
214 index: context.config.suggestions.indexOf( text )
217 if ( source === 'keyboard' ) {
218 window.location.assign( url );
219 // prevent default and stop propagation
224 // allow the form to be submitted
228 function specialRenderFunction( query, context ) {
230 formData = getFormData( context );
232 // linkParams object is modified and reused
233 formData.linkParams[ formData.textParam ] = query;
235 mw.track( 'mediawiki.searchSuggest', {
236 action: 'render-one',
238 index: context.config.suggestions.indexOf( query )
241 if ( mw.user.options.get( 'search-match-redirect' ) && $el.children().length === 0 ) {
245 .addClass( 'special-label' )
246 .text( mw.msg( 'searchsuggest-containing' ) ),
248 .addClass( 'special-query' )
253 $el.find( '.special-query' )
257 // eslint-disable-next-line no-jquery/no-class-state
258 if ( $el.parent().hasClass( 'mw-searchSuggest-link' ) ) {
259 $el.parent().attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' );
263 .attr( 'href', formData.baseHref + $.param( formData.linkParams ) + '&fulltext=1' )
264 .addClass( 'mw-searchSuggest-link' )
269 // Generic suggestions functionality for all search boxes
270 const searchboxesSelectors = [
271 // Primary searchbox on every page in standard skins
273 // Generic selector for skins with multiple searchboxes (used by CologneBlue)
274 // and for MediaWiki itself (special pages with page title inputs)
277 $( searchboxesSelectors.join( ', ' ) )
279 fetch: function ( query, response, maxRows ) {
280 const node = this[ 0 ];
282 api = api || new mw.Api();
284 $.data( node, 'request', mw.searchSuggest.request( api, query, response, maxRows ) );
286 cancel: function () {
287 const node = this[ 0 ],
288 request = $.data( node, 'request' );
292 $.removeData( node, 'request' );
296 render: renderFunction,
297 select: function () {
298 // allow the form to be submitted
303 before: onBeforeUpdate,
309 .on( 'paste cut drop', function () {
310 // make sure paste and cut events from the mouse and drag&drop events
311 // trigger the keypress handler and cause the suggestions to update
312 $( this ).trigger( 'keypress' );
314 // In most skins (at least Monobook and Vector), the font-size is messed up in <body>.
315 // (they use 2 elements to get a sensible font-height). So, instead of making exceptions for
316 // each skin or adding more stylesheets, just copy it from the active element so auto-fit.
318 const $this = $( this );
320 .data( 'suggestions-context' )
321 .data.$container.css( 'fontSize', $this.css( 'fontSize' ) );
324 // Ensure that the thing is actually present!
325 if ( $searchRegion.length === 0 ) {
326 // Don't try to set anything up if simpleSearch is disabled sitewide.
327 // The loader code loads us if the option is present, even if we're
328 // not actually enabled (anymore).
332 // Special suggestions functionality and tracking for skin-provided search box
333 $searchInput.suggestions( {
335 before: onBeforeUpdate,
339 render: renderFunction,
340 select: selectFunction
343 render: specialRenderFunction,
344 select: function ( $input, source ) {
345 const context = $input.data( 'suggestionsContext' ),
347 if ( source === 'mouse' ) {
348 // mouse click won't trigger form submission, so we need to send a click event
349 mw.track( 'mediawiki.searchSuggest', {
350 action: 'click-result',
351 numberOfResults: context.config.suggestions.length,
352 index: context.config.suggestions.indexOf( text )
355 $input.closest( 'form' )
362 .attr( 'name', 'fulltext' )
365 return true; // allow the form to be submitted
368 $region: $searchRegion
371 const $searchForm = $searchInput.closest( 'form' );
373 // Track the form submit event.
374 // Note that the form is mainly submitted for manual user input;
375 // selecting a suggestion is tracked as a click instead (see selectFunction()).
376 .on( 'submit', () => {
377 const context = $searchInput.data( 'suggestionsContext' );
378 mw.track( 'mediawiki.searchSuggest', {
379 action: 'submit-form',
380 numberOfResults: context.config.suggestions.length,
381 $form: context.config.$region.closest( 'form' ),
382 inputLocation: getInputLocation( context ),
383 index: context.config.suggestions.indexOf(
384 context.data.$textbox.val()
389 // Check to see if the fulltext search button is placed before the go search button
390 if ( $searchForm.find( '.mw-fallbackSearchButton ~ .searchButton' ).length ) {
391 // Submitting the form with enter should always trigger "search within pages"
392 // for JavaScript capable browsers.
393 // If it is, remove the "full text search" fallback button.
394 // In skins, where the "full text search" button
395 // precedes the "search by title" button, e.g. Vector this is done for
396 // non-JavaScript support. If the "search by title" button is first,
397 // and two search buttons are shown e.g. MonoBook no change is needed.
398 $searchForm.find( '.mw-fallbackSearchButton' ).remove();