feat: rework search module (#386)

* feat: rewrite search module (WIP)
There are some caveats because it is a WIP
- Messages are not i18n yet
- Missing placeholder suggestion thumbnail
- Only REST mode works
- Missing greeting message when there is no search query
- Code might look like a mess (I learned JS not long ago)

* refactor: remove old search module

* feat: clean up search suggestion styles

* feat: hide overflow for suggestion text

* feat: add action API and various cleanup

* feat: re-add abort controller

* feat: add message support and tweaks

* feat: use virtual config instead of ResourceLoader hook

* fix: missing comma in const definition

* feat: add ARIA attributes
This commit is contained in:
alistair3149 2021-05-17 16:34:14 -04:00 committed by GitHub
parent 2987e23290
commit b2bd79196d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 804 additions and 1104 deletions

View file

@ -47,8 +47,8 @@ Name | Description | Values | Default
Name | Description | Values | Default
:--- | :--- | :--- | :---
`$wgCitizenEnableSearch` | Enable or disable rich search suggestions |`true` - enable; `false` - disable | `true`
`$wgCitizenSearchUseREST` | Enable or disable the use of REST API search endpoint |`true` - enable; `false` - disable | `false`
`$wgCitizenSearchDescriptionSource` | Source of description text on search suggestions (only takes effect if `$wgCitizenUseREST` is false) | `wikidata` - Use description provided by [WikibaseLib](Extension:WikibaseLib) or [ShortDescription](https://www.mediawiki.org/wiki/Extension:ShortDescription); `textextracts` - Use description provided by [TextExtracts](https://www.mediawiki.org/wiki/Extension:TextExtracts); `pagedescription` - Use description provided by [Description2](https://www.mediawiki.org/wiki/Extension:Description2) or any other extension that sets the `description` page property | `textextracts`
`$wgCitizenSearchGateway` | Which gateway to use for fetching search suggestion |`mwActionApi`; `mwRestApi` | `mwActionApi`
`$wgCitizenSearchDescriptionSource` | Source of description text on search suggestions (only takes effect if `$wgCitizenSearchGateway` is `mwActionApi`) | `wikidata` - Use description provided by [WikibaseLib](Extension:WikibaseLib) or [ShortDescription](https://www.mediawiki.org/wiki/Extension:ShortDescription); `textextracts` - Use description provided by [TextExtracts](https://www.mediawiki.org/wiki/Extension:TextExtracts); `pagedescription` - Use description provided by [Description2](https://www.mediawiki.org/wiki/Extension:Description2) or any other extension that sets the `description` page property | `textextracts`
`$wgCitizenMaxSearchResults` | Max number of search suggestions | Integer > 0 | `6`
### Image lazyload

View file

@ -31,6 +31,7 @@
"citizen-jumptotop": "Back to top",
"citizen-search-fulltext": "Search pages containing",
"citizen-search-fulltext-empty": "Type to start searching",
"citizen-tagline-ns-talk": "Discussion page of {{SUBJECTPAGENAME}}",
"citizen-tagline-ns-project": "Information about {{SITENAME}}",

View file

@ -24,6 +24,7 @@
"citizen-action-addsection": "Used in the Citizen skin. See for example {{canonicalurl:Talk:Main_Page|useskin=vector}}\n{{Identical|Add topic}}. Same as vector-action-addsection in Vector skin.",
"citizen-jumptotop": "Label for link to jump to top of page",
"citizen-search-fulltext": "Fulltext search suggestion",
"citizen-search-fulltext-empty": "Helper text in the search suggestion when there are no search query",
"citizen-tagline-ns-talk": "Tagline for pages in talk namespace",
"citizen-tagline-ns-project": "Tagline for pages in project namespace",
"citizen-tagline-ns-file": "Tagline for pages in file namespace",

View file

@ -26,69 +26,44 @@ declare( strict_types=1 );
namespace Citizen\Hooks;
use Config;
use ConfigException;
use MediaWiki\MediaWikiServices;
use MediaWiki\ResourceLoader\Hook\ResourceLoaderGetConfigVarsHook;
use Skin;
use ResourceLoaderContext;
/**
* Hooks to run relating to the resource loader
*/
class ResourceLoaderHooks implements ResourceLoaderGetConfigVarsHook {
class ResourceLoaderHooks {
/**
* ResourceLoaderGetConfigVars hook handler for setting a config variable
* @see https://www.mediawiki.org/wiki/Manual:Hooks/ResourceLoaderGetConfigVars
* @param array &$vars
* @param Skin $skin
* Passes config variables to skins.citizen.scripts ResourceLoader module.
* @param ResourceLoaderContext $context
* @param Config $config
* @return array
*/
public function onResourceLoaderGetConfigVars( array &$vars, $skin, Config $config ): void {
// Check if search suggestion is enabled
try {
$vars['wgCitizenEnableSearch'] = self::getSkinConfig( 'CitizenEnableSearch' );
} catch ( ConfigException $e ) {
// Should not happen
$vars['wgCitizenEnableSearch'] = true;
}
// Only check for search config if search is enabled
// Since the module won't be loaded if it is not enabled
if ( $vars['wgCitizenEnableSearch'] === true ) {
try {
$vars['wgCitizenSearchUseREST'] = self::getSkinConfig( 'CitizenSearchUseREST' );
} catch ( ConfigException $e ) {
// Should not happen
$vars['wgCitizenSearchUseREST'] = false;
}
try {
$vars['wgCitizenSearchDescriptionSource'] = self::getSkinConfig( 'CitizenSearchDescriptionSource' );
} catch ( ConfigException $e ) {
// Should not happen
$vars['wgCitizenSearchDescriptionSource'] = 'textextracts';
}
try {
$vars['wgCitizenMaxSearchResults'] = self::getSkinConfig( 'CitizenMaxSearchResults' );
} catch ( ConfigException $e ) {
// Should not happen
$vars['wgCitizenMaxSearchResults'] = 6;
}
// Core config so skip try catch
$vars['wgSearchSuggestCacheExpiry'] = self::getSkinConfig( 'SearchSuggestCacheExpiry' );
}
public static function getCitizenResourceLoaderConfig(
ResourceLoaderContext $context,
Config $config
) {
return [
'wgCitizenEnableSearch' => $config->get( 'CitizenEnableSearch' ),
];
}
/**
* Get a skin configuration variable.
*
* @param string $name Name of configuration option.
* @return mixed Value configured.
* @throws ConfigException
* Passes config variables to skins.citizen.search ResourceLoader module.
* @param ResourceLoaderContext $context
* @param Config $config
* @return array
*/
private static function getSkinConfig( $name ) {
return MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'Citizen' )->get( $name );
public static function getCitizenSearchResourceLoaderConfig(
ResourceLoaderContext $context,
Config $config
) {
return [
'wgCitizenSearchGateway' => $config->get( 'CitizenSearchGateway' ),
'wgCitizenSearchDescriptionSource' => $config->get( 'CitizenSearchDescriptionSource' ),
'wgCitizenMaxSearchResults' => $config->get( 'CitizenMaxSearchResults' ),
'wgScriptPath' => $config->get( 'ScriptPath' ),
'wgSearchSuggestCacheExpiry' => $config->get( 'SearchSuggestCacheExpiry' ),
];
}
}

View file

@ -266,12 +266,6 @@ class SkinCitizen extends SkinMustache {
$options['scripts'][] = 'skins.citizen.scripts.toc';
}
// Search suggestion
if ( $this->getConfigValue( 'CitizenEnableSearch' ) === true ) {
$options['styles'][] = 'skins.citizen.styles.search';
$options['styles'][] = 'skins.citizen.icons.search';
}
// Image lazyload
if ( $this->getConfigValue( 'CitizenEnableLazyload' ) === true ) {
$options['scripts'][] = 'skins.citizen.scripts.lazyload';

View file

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56">
<path fill="#eaecf0" d="M0 0h56v56H0"/>
<path fill="#72777d" d="M36.4 13.5H17.8v24.9c0 1.4.9 2.3 2.3 2.3h18.7v-25c.1-1.4-1-2.2-2.4-2.2zM30.2 17h5.1v6.4h-5.1V17zm-8.8 0h6v1.8h-6V17zm0 4.6h6v1.8h-6v-1.8zm0 15.5v-1.8h13.8v1.8H21.4zm13.8-4.5H21.4v-1.8h13.8v1.8zm0-4.7H21.4v-1.8h13.8v1.8z"/>
</svg>

Before

Width:  |  Height:  |  Size: 401 B

View file

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path fill="#36c" d="M7.5 13c3.04 0 5.5-2.46 5.5-5.5S10.54 2 7.5 2 2 4.46 2 7.5 4.46 13 7.5 13zm4.55.46A7.432 7.432 0 0 1 7.5 15C3.36 15 0 11.64 0 7.5S3.36 0 7.5 0C11.64 0 15 3.36 15 7.5c0 1.71-.57 3.29-1.54 4.55l6.49 6.49-1.41 1.41-6.49-6.49z"/>
<path fill="#36c" d="M4 5h7v1H4zm0 2h7v1H4zm0 2h5.444v1H4z"/>
</svg>

Before

Width:  |  Height:  |  Size: 383 B

View file

@ -1,31 +0,0 @@
{{!
@typedef object props
@prop string id of list item element
@prop string thumb-url of list item
@prop string title of list item
@prop string description of list item
props[] array-suggestion-links iterable list of search suggestion item
string html-fulltext-url
string msg-citizen-search-fulltext
string html-searchstring
}}
<div class="suggestions-dropdown">
{{#array-suggestion-links}}
<a class="suggestion-link"{{#id}} id="{{.}}"{{/id}} href="{{{url}}}">
<div class="suggestion-thumbnail"{{#thumb-url}} style="background-image:url({{.}}){{/thumb-url}}"></div>
<div class="suggestion-text">
<h3 class="suggestion-title">{{{title}}}</h3>
<p class="suggestion-description">{{{description}}}</p>
</div>
</a>
{{/array-suggestion-links}}
<a id="suggestion-special" href="{{{html-fulltext-url}}}">
<div id="suggestion-special-icon"></div>
<div id="suggestion-special-text">
{{{msg-citizen-search-fulltext}}}
<em class="suggestion-highlight">{{{html-searchstring}}}</em>
</div>
</a>
</div>

View file

@ -1,31 +0,0 @@
/* eslint-disable */
/**
* Based on https://gerrit.wikimedia.org/g/wikimedia/portals/+/refs/heads/master
* See T219590 for more details
*/
/* global WMTypeAhead, _, addEvent */
( function ( WMTypeAhead ) {
let inputEvent,
searchInput = document.getElementById( 'searchInput' ),
typeAhead = new WMTypeAhead( 'searchform', 'searchInput' );
/**
* Testing for 'input' event and falling back to 'propertychange' event for IE.
*/
if ( 'oninput' in document ) {
inputEvent = 'input';
} else {
inputEvent = 'propertychange';
}
/**
* Attaching type-ahead query action to 'input' event.
*/
addEvent( searchInput, inputEvent, _.debounce( function () {
typeAhead.query( searchInput.value );
}, 100 ) );
}( WMTypeAhead ) );

View file

@ -1,105 +0,0 @@
/*
Copyright (c) 2009-2015 Jeremy Ashkenas, DocumentCloud and Investigative
Reporters & Editors
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
*/
/*
This is a partial version of the library that only includes a few
underscore methods.
*/
/* eslint-disable */
var _ = _ || {};
_.now = Date.now || function() {
return new Date().getTime();
};
_.throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) { options = {};
}
var later = function() {
previous = options.leading === false ? 0 : _.now();
timeout = null;
result = func.apply(context, args);
if (!timeout) { context = args = null;
}
};
return function() {
var now = _.now();
if (!previous && options.leading === false) { previous = now;
}
var remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) { context = args = null;
}
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
};
_.debounce = function(func, wait, immediate) {
var timeout, args, context, timestamp, result;
var later = function() {
var last = _.now() - timestamp;
if (last < wait && last >= 0) {
timeout = setTimeout(later, wait - last);
} else {
timeout = null;
if (!immediate) {
result = func.apply(context, args);
if (!timeout) { context = args = null;
}
}
}
};
return function() {
context = this;
args = arguments;
timestamp = _.now();
var callNow = immediate && !timeout;
if (!timeout) { timeout = setTimeout(later, wait);
}
if (callNow) {
result = func.apply(context, args);
context = args = null;
}
return result;
};
};

View file

@ -1,611 +0,0 @@
/* eslint-disable */
/**
* Based on https://gerrit.wikimedia.org/g/wikimedia/portals/+/refs/heads/master
* See T219590 for more details
*/
/**
* Below are additional dependency extracted from polyfills.js
* TODO: Optimize and clear unneeded code
*/
/**
* Detects reported or approximate device pixel ratio.
* * 1.0 means 1 CSS pixel is 1 hardware pixel
* * 2.0 means 1 CSS pixel is 2 hardware pixels
* * etc.
*
* Uses window.devicePixelRatio if available, or CSS media queries on IE.
*
* @return {number} Device pixel ratio
*/
function getDevicePixelRatio() {
if ( window.devicePixelRatio !== undefined ) {
// Most web browsers:
// * WebKit (Safari, Chrome, Android browser, etc)
// * Opera
// * Firefox 18+
return window.devicePixelRatio;
} else if ( window.msMatchMedia !== undefined ) {
// Windows 8 desktops / tablets, probably Windows Phone 8
//
// IE 10 doesn't report pixel ratio directly, but we can get the
// screen DPI and divide by 96. We'll bracket to [1, 1.5, 2.0] for
// simplicity, but you may get different values depending on zoom
// factor, size of screen and orientation in Metro IE.
if ( window.msMatchMedia( '(min-resolution: 192dpi)' ).matches ) {
return 2;
} else if ( window.msMatchMedia( '(min-resolution: 144dpi)' ).matches ) {
return 1.5;
} else {
return 1;
}
} else {
// Legacy browsers...
// Assume 1 if unknown.
return 1;
}
}
function addEvent( obj, evt, fn ) {
if ( !obj ) {
return;
}
if ( obj.addEventListener ) {
obj.addEventListener( evt, fn, false );
} else if ( obj.attachEvent ) {
obj.attachedEvents.push( [ obj, evt, fn ] );
obj.attachEvent( 'on' + evt, fn );
}
}
/**
* WMTypeAhead.
* Displays search suggestions with thumbnail and description
* as user types into an input field.
*
* @constructor
* @param {string} appendTo - ID of a container element that the suggestions will be appended to.
* @param {string} searchInput - ID of a search input whose value will be used to generate
* search suggestions.
*
* @return {Object} Returns an object with the following properties:
* @return {HTMLElement} return.typeAheadEl The type-ahead DOM object.
* @return {Function} return.query A function that loads the type-ahead suggestions.
*
* @example
* var typeAhead = new WMTypeAhead('containerID', 'inputID');
* typeAhead.query('search string', 'en');
*
*/
window.WMTypeAhead = function ( appendTo, searchInput ) {
let typeAheadID = 'typeahead-suggestions',
typeAheadEl = document.getElementById( typeAheadID ), // Type-ahead DOM element.
appendEl = document.getElementById( appendTo ),
searchEl = document.getElementById( searchInput ),
server = mw.config.get( 'wgServer' ),
scriptPath = mw.config.get( 'wgScriptPath' ),
searchurl = server + scriptPath + '/index.php?title=Special%3ASearch&search=',
thumbnailSize = Math.round( getDevicePixelRatio() * 80 ),
useREST = mw.config.get( 'wgCitizenSearchUseREST' ),
descriptionSource = mw.config.get( 'wgCitizenSearchDescriptionSource' ),
maxSearchResults = mw.config.get( 'wgCitizenMaxSearchResults' ),
cacheExpiry = mw.config.get( 'wgSearchSuggestCacheExpiry' ),
searchString,
typeAheadItems,
activeItem,
ssActiveIndex,
api = new mw.Api();
// Only create typeAheadEl once on page.
if ( !typeAheadEl ) {
typeAheadEl = document.createElement( 'div' );
typeAheadEl.id = typeAheadID;
appendEl.appendChild( typeAheadEl );
}
/**
* Keeps track of the search query callbacks. Consists of an array of
* callback functions and an index that keeps track of the order of requests.
* Callbacks are deleted by replacing the callback function with a no-op.
*/
window.callbackStack = {
queue: {},
index: -1,
incrementIndex: function () {
this.index += 1;
return this.index;
},
addCallback: function ( func ) {
const index = this.incrementIndex();
this.queue[ index ] = func( index );
return index;
},
deleteSelfFromQueue: function ( i ) {
delete this.queue[ i ];
},
deletePrevCallbacks: function ( j ) {
let callback;
this.deleteSelfFromQueue( j );
for ( callback in this.queue ) {
if ( callback < j ) {
this.queue[ callback ] = this.deleteSelfFromQueue.bind(
window.callbackStack, callback
);
}
}
}
};
/**
* Maintains the 'active' state on search suggestions.
* Makes sure the 'active' element is synchronized between mouse and keyboard usage,
* and cleared when new search suggestions appear.
*/
ssActiveIndex = {
index: -1,
max: maxSearchResults,
setMax: function ( x ) {
this.max = x;
},
increment: function ( i ) {
this.index += i;
if ( this.index < 0 ) {
this.setIndex( this.max - 1 );
} // Index reaches top
if ( this.index === this.max ) {
this.setIndex( 0 );
} // Index reaches bottom
return this.index;
},
setIndex: function ( i ) {
if ( i <= this.max - 1 ) {
this.index = i;
}
return this.index;
},
clear: function () {
this.setIndex( -1 );
}
};
/**
* Removed the actual child nodes from typeAheadEl
* @see {typeAheadEl}
*/
function clearTypeAheadElements() {
if ( typeof typeAheadEl === 'undefined' ) {
return;
}
while ( typeAheadEl.firstChild !== null ) {
typeAheadEl.removeChild( typeAheadEl.firstChild );
}
}
/**
* Removes the type-ahead suggestions from the DOM.
* Reason for timeout: The typeahead is set to clear on input blur.
* When a user clicks on a search suggestion, they triggers the input blur
* and remove the typeahead before a click event is registered.
* The timeout makes it so a click on the search suggestion is registered before
* an input blur.
* 300ms is used to account for the click delay on mobile devices.
*
*/
function clearTypeAhead() {
setTimeout( function () {
clearTypeAheadElements();
ssActiveIndex.clear();
}, 300 );
}
/**
* Manually redirects the page to the href of a given element.
*
* For Chrome on Android to solve T221628.
* When search suggestions below the fold are clicked, the blur event
* on the search input is triggered and the page scrolls the search input
* into view. However, the originating click event does not redirect
* the page.
*
* @param {Event} e
*/
function forceLinkFollow( e ) {
const el = e.relatedTarget;
if ( el && /suggestion-link/.test( el.className ) ) {
window.location = el.href;
}
}
/**
* Inserts script element containing the Search API results into document head.
* The script itself calls the 'portalOpensearchCallback' callback function,
*
* @param {string} string - query string to search.
* @param {string} lang - ISO code of language to search in.
*/
function loadQueryScript( string ) {
let callbackIndex,
searchQuery;
// Variables declared in parent function.
searchString = encodeURIComponent( string );
if ( searchString.length === 0 ) {
clearTypeAhead();
return;
}
callbackIndex = window.callbackStack.addCallback( window.portalOpensearchCallback );
// Check if use REST API
if ( useREST ) {
fetch(`${scriptPath}/rest.php/v1/search/title?q=${searchString}&limit=${maxSearchResults}`)
.then(async function (response) {
var data = await response.json();
clearTypeAheadElements();
window.callbackStack.queue[ callbackIndex ]( data, string );
} );
} else {
// TODO: Not sure if static cache expiry is a good idea
// A value based on the size of the wiki and the
// length of the search string might be a better idea
searchQuery = {
action: 'query',
smaxage: cacheExpiry,
maxage: cacheExpiry,
generator: 'prefixsearch',
prop: 'pageprops|pageimages',
redirects: '',
ppprop: 'displaytitle',
piprop: 'thumbnail',
pithumbsize: thumbnailSize,
pilimit: maxSearchResults,
gpssearch: string,
gpsnamespace: 0,
gpslimit: maxSearchResults
};
switch ( descriptionSource ) {
case 'wikidata':
searchQuery.prop += '|description';
break;
case 'textextracts':
searchQuery.prop += '|extracts';
searchQuery.exchars = '60';
searchQuery.exintro = '1';
searchQuery.exlimit = maxSearchResults;
searchQuery.explaintext = '1';
break;
case 'pagedescription':
searchQuery.prop += '|pageprops';
searchQuery.ppprop = 'description';
break;
}
api.get( searchQuery )
.done( ( data ) => {
clearTypeAheadElements();
window.callbackStack.queue[ callbackIndex ]( data, string );
} );
}
} // END loadQueryScript
/**
* Highlights the part of the suggestion title that matches the search query.
* Used inside the generateTemplateString function.
*
* @param {string} title - The title of the search suggestion.
* @param {string} searchString - The string to highlight.
* @return {string} The title with highlighted part in an <em> tag.
*/
function highlightTitle( title, searchString ) {
let sanitizedSearchString = mw.html.escape( mw.util.escapeRegExp( searchString ) ),
searchRegex = new RegExp( sanitizedSearchString, 'i' ),
startHighlightIndex = title.search( searchRegex ),
formattedTitle = mw.html.escape( title ),
endHighlightIndex,
strong,
beforeHighlight,
aferHighlight;
if ( startHighlightIndex >= 0 ) {
endHighlightIndex = startHighlightIndex + sanitizedSearchString.length;
strong = title.substring( startHighlightIndex, endHighlightIndex );
beforeHighlight = title.substring( 0, startHighlightIndex );
aferHighlight = title.substring( endHighlightIndex, title.length );
formattedTitle = beforeHighlight + mw.html.element( 'em', { class: 'suggestion-highlight' }, strong ) + aferHighlight;
}
return formattedTitle;
} // END highlightTitle
/**
* Get indiviual suggestion items
*
* @param {Array} suggestions - An array of search suggestion results.
* @return {Array} props - An array of formatted search suggestion results for Mustache
*/
function getSuggestionProps( suggestions ) {
let props = [],
i,
page,
suggestionLinkID,
suggestionDescription,
sanitizedThumbURL,
pageDescription;
for ( i = 0; i < suggestions.length; i++ ) {
if ( !suggestions[ i ] ) {
continue;
}
page = suggestions[ i ];
// Ensure that the value from the previous iteration isn't used
suggestionDescription = '';
sanitizedThumbURL = false;
pageDescription = '';
// Add ID if first or last suggestion
if ( i === 0 ) {
suggestionLinkID = 'suggestion-link-first';
} else if ( i === suggestions.length - 1 ) {
suggestionLinkID = 'suggestion-link-last';
} else {
suggestionLinkID = '';
}
if ( useREST ) {
suggestionDescription = page.description;
if ( page.thumbnail && page.thumbnail.url ) {
sanitizedThumbURL = page.thumbnail.url.replace( /"/g, '%22' );
}
} else {
switch ( descriptionSource ) {
case 'wikidata':
pageDescription = page.description || '';
break;
case 'textextracts':
pageDescription = page.extract || '';
break;
case 'pagedescription':
pageDescription = page.pageprops.description.substring(0, 60) + '...' || '';
break;
}
// Check if description exists
if ( pageDescription ) {
// If the description is an array, use the first item
if ( typeof pageDescription === 'object' && pageDescription[ 0 ] ) {
suggestionDescription = pageDescription[ 0 ].toString();
} else {
// Otherwise, use the description as is.
suggestionDescription = pageDescription.toString();
}
// Filter out no text from TextExtracts
if ( suggestionDescription === '...' ) {
suggestionDescription = '';
}
}
if ( page.thumbnail && page.thumbnail.source ) {
sanitizedThumbURL = page.thumbnail.source.replace( /"/g, '%22' );
}
}
if ( sanitizedThumbURL ) {
sanitizedThumbURL = sanitizedThumbURL.replace( /'/g, '%27' );
}
props[ i ] = {
'id': suggestionLinkID,
'url': server + ( new mw.Title( page.title, page.ns ?? 0 ).getUrl() ),
'thumb-url': sanitizedThumbURL,
'title': highlightTitle( page.title, searchString ),
'description': suggestionDescription
};
}
return props;
}
/**
* Generates the HTML template based on an array of search suggestions.
* TODO: Maybe ES6 export is faster?
*
* @param {Array} suggestions - An array of search suggestion results.
* @return {String} HTML of the search suggestion results.
*/
function generateSuggestionsHTML( suggestions ) {
let template,
data,
html;
// Initalize Mustache template
template = mw.template.get(
'skins.citizen.scripts.search',
'resources/skins.citizen.scripts.search/templates/suggestions.mustache'
);
data = {
'html-fulltext-url': searchurl + searchString + '&fulltext=1',
'msg-citizen-search-fulltext': mw.message( 'citizen-search-fulltext' ).text(),
'html-searchstring': decodeURI( searchString ),
'array-suggestion-links': []
};
if ( suggestions.length > 0 ) {
data['array-suggestion-links'] = getSuggestionProps( suggestions );
}
html = template.render( data )[1].outerHTML;
return html;
} // END generateTemplate
/**
* - Removes 'active' class from a collection of elements.
* - Adds 'active' class to an item if missing.
* - Removes 'active' class from item if present.
*
* @param {HTMLElement} item Item to add active class to.
* @param {NodeList} collection Sibling items.
*/
function toggleActiveClass( item, collection ) {
let activeClass = ' active', // Prefixed with space.
colItem,
i;
for ( i = 0; i < collection.length; i++ ) {
colItem = collection[ i ];
// Remove the class name from everything except item.
if ( colItem !== item ) {
colItem.className = colItem.className.replace( activeClass, '' );
} else {
// If item has class name, remove it
if ( / active/.test( item.className ) ) {
item.className = item.className.replace( activeClass, '' );
} else {
// It item doesn't have class name, add it.
item.className += activeClass;
ssActiveIndex.setIndex( i );
}
}
}
}
/**
* Search API callback. Returns a closure that holds the index of the request.
* Deletes previous callbacks based on this index. This prevents callbacks for old
* requests from executing. Then:
* - parses the search results
* - generates the template String
* - inserts the template string into the DOM
* - attaches event listeners on each suggestion item.
*
* @param {number} i
* @return {Function}
*/
window.portalOpensearchCallback = function ( i ) {
let callbackIndex = i,
orderedResults = [],
suggestions,
item,
result,
listEl;
return function ( xhrResults, queryString ) {
// console.log(xhrResults);
window.callbackStack.deletePrevCallbacks( callbackIndex );
if ( document.activeElement !== searchEl ) {
return;
}
if ( useREST ) {
suggestions = xhrResults.pages;
} else {
suggestions = ( xhrResults.query && xhrResults.query.pages ) ?
xhrResults.query.pages : [];
}
if ( suggestions.length === 0 ) {
typeAheadEl.innerHTML = generateSuggestionsHTML( [] );
return;
}
if ( useREST ) {
// Already ordered
orderedResults = suggestions;
} else {
for ( item in suggestions ) {
if ( Object.prototype.hasOwnProperty.call( suggestions, item ) ) {
result = suggestions[ item ];
orderedResults[ result.index - 1 ] = result;
}
}
}
ssActiveIndex.setMax( orderedResults.length );
ssActiveIndex.clear();
typeAheadEl.innerHTML = generateSuggestionsHTML( orderedResults );
typeAheadItems = typeAheadEl.childNodes[ 0 ].children;
// Attaching hover events
for ( i = 0; i < typeAheadItems.length; i++ ) {
listEl = typeAheadItems[ i ];
// Requires the addEvent global polyfill
addEvent( listEl, 'mouseenter', toggleActiveClass.bind( this, listEl, typeAheadItems ) );
addEvent( listEl, 'mouseleave', toggleActiveClass.bind( this, listEl, typeAheadItems ) );
}
};
};
/**
* Keyboard events: up arrow, down arrow and enter.
* moves the 'active' suggestion up and down.
*
* @param {event} event
*/
function keyboardEvents( event ) {
let e = event || window.event,
keycode = e.which || e.keyCode,
suggestionItems,
searchSuggestionIndex;
if ( !typeAheadEl.firstChild ) {
return;
}
if ( keycode === 40 || keycode === 38 ) {
suggestionItems = typeAheadEl.firstChild.children;
if ( keycode === 40 ) {
searchSuggestionIndex = ssActiveIndex.increment( 1 );
} else {
searchSuggestionIndex = ssActiveIndex.increment( -1 );
}
activeItem = ( suggestionItems ) ? suggestionItems[ searchSuggestionIndex ] : false;
toggleActiveClass( activeItem, suggestionItems );
}
if ( keycode === 13 && activeItem ) {
if ( e.preventDefault ) {
e.preventDefault();
} else {
( e.returnValue = false );
}
activeItem.children[ 0 ].click();
}
}
addEvent( searchEl, 'keydown', keyboardEvents );
addEvent( searchEl, 'blur', function ( e ) {
clearTypeAhead();
// Don't interfere with special clicks (e.g. to open in new tab)
if ( !( e.which !== 1 || e.altKey || e.ctrlKey || e.shiftKey || e.metaKey ) ) {
forceLinkFollow( e );
}
} );
return {
typeAheadEl: typeAheadEl,
query: loadQueryScript
};
};

View file

@ -170,14 +170,15 @@ function initCheckboxHack( window, input, target ) {
* @return {void}
*/
function initSearch( window ) {
const searchForm = document.getElementById( 'searchform' ),
const searchConfig = require( './config.json' ).wgCitizenEnableSearch,
searchForm = document.getElementById( 'searchform' ),
searchInput = document.getElementById( SEARCH_INPUT_ID );
initCheckboxHack( window, searchInput, searchForm );
if ( mw.config.get( 'wgCitizenEnableSearch' ) ) {
if ( searchConfig ) {
setLoadingIndicatorListeners( searchForm, true, renderSearchLoadingIndicator );
loadSearchModule( searchInput, 'skins.citizen.scripts.search', () => {
loadSearchModule( searchInput, 'skins.citizen.search', () => {
setLoadingIndicatorListeners( searchForm, false, renderSearchLoadingIndicator );
} );
} else {

View file

@ -0,0 +1,55 @@
/**
* @typedef {Object} Results
* @property {string} id The page ID of the page
* @property {string} title The title of the page.
* @property {string} description The description of the page.
* @property {string} thumbnail The url of the thumbnail of the page.
*
* @global
*/
const gatewayConfig = require( '../config.json' ).wgCitizenSearchGateway;
/**
* Setup the gateway based on wiki configuration
*
* @return {module}
*/
function getGateway() {
switch ( gatewayConfig ) {
case 'mwActionApi':
return require( './mwActionApi.js' );
case 'mwRestApi':
return require( './mwRestApi.js' );
default:
throw new Error( 'Unknown search gateway' );
}
}
/**
* Fetch suggestion from gateway and return the results object
*
* @param {string} searchQuery
* @param {AbortController} controller
* @return {Object} Results
*/
async function getResults( searchQuery, controller ) {
const gateway = getGateway();
const signal = controller.signal;
/* eslint-disable-next-line compat/compat */
const response = await fetch( gateway.getUrl( searchQuery ), { signal } );
if ( !response.ok ) {
const message = 'Uh oh, a wild error appears! ' + response.status;
throw new Error( message );
}
const data = await response.json();
return gateway.convertDataToResults( data );
}
module.exports = {
getResults: getResults
};

View file

@ -0,0 +1,108 @@
const config = require( '../config.json' ),
descriptionSource = config.wgCitizenSearchDescriptionSource;
/**
* Build URL used for fetch request
*
* @param {string} input
* @return {string} url
*/
function getUrl( input ) {
const endpoint = config.wgScriptPath + '/api.php?format=json',
cacheExpiry = config.wgSearchSuggestCacheExpiry,
maxResults = config.wgCitizenMaxSearchResults,
query = {
action: 'query',
smaxage: cacheExpiry,
maxage: cacheExpiry,
generator: 'prefixsearch',
prop: 'pageprops|pageimages',
redirects: '',
ppprop: 'displaytitle',
piprop: 'thumbnail',
pithumbsize: 200,
pilimit: maxResults,
gpssearch: input,
gpsnamespace: 0,
gpslimit: maxResults
};
switch ( descriptionSource ) {
case 'wikidata':
query.prop += '|description';
break;
case 'textextracts':
query.prop += '|extracts';
query.exchars = '60';
query.exintro = '1';
query.exlimit = maxResults;
query.explaintext = '1';
break;
case 'pagedescription':
query.prop += '|pageprops';
query.ppprop = 'description';
break;
}
let queryString = '';
for ( const property in query ) {
queryString += '&' + property + '=' + query[ property ];
}
return endpoint + queryString;
}
/**
* Map raw response to Results object
*
* @param {Object} data
* @return {Object} Results
*/
function convertDataToResults( data ) {
const getTitle = ( item ) => {
if ( item.pageprops && item.pageprops.displaytitle ) {
return item.pageprops.displaytitle;
} else {
return item.title;
}
};
const getDescription = ( item ) => {
switch ( descriptionSource ) {
case 'wikidata':
return item.description || '';
case 'textextracts':
return item.extract || '';
case 'pagedescription':
return item.pageprops.description.substring( 0, 60 ) + '...' || '';
}
};
const results = [];
/* eslint-disable-next-line compat/compat, es/no-object-values */
data = Object.values( data.query.pages );
// Sort the data with the index property since it is not in order
data.sort( ( a, b ) => {
return a.index - b.index;
} );
for ( let i = 0; i < data.length; i++ ) {
results[ i ] = {
id: data[ i ].pageid,
title: getTitle( data[ i ] ),
description: getDescription( data[ i ] )
};
if ( data[ i ].thumbnail && data[ i ].thumbnail.source ) {
results[ i ].thumbnail = data[ i ].thumbnail.source;
}
}
return results;
}
module.exports = {
getUrl: getUrl,
convertDataToResults: convertDataToResults
};

View file

@ -0,0 +1,44 @@
const config = require( '../config.json' );
/**
* Build URL used for fetch request
*
* @param {string} input
* @return {string} url
*/
function getUrl( input ) {
const endpoint = config.wgScriptPath + '/rest.php/v1/search/title?q=',
query = '&limit=' + config.wgCitizenMaxSearchResults;
return endpoint + input + query;
}
/**
* Map raw response to Results object
*
* @param {Object} data
* @return {Object} Results
*/
function convertDataToResults( data ) {
const results = [];
data = data.pages;
for ( let i = 0; i < data.length; i++ ) {
results[ i ] = {
id: data[ i ].id,
title: data[ i ].title,
description: data[ i ].description
};
if ( data[ i ].thumbnail && data[ i ].thumbnail.url ) {
results[ i ].thumbnail = data[ i ].thumbnail.url;
}
}
return results;
}
module.exports = {
getUrl: getUrl,
convertDataToResults: convertDataToResults
};

View file

@ -0,0 +1,14 @@
/**
* @return {void}
*/
function initSearchLoader() {
const searchForm = document.getElementById( 'searchform' ),
searchInput = document.getElementById( 'searchInput' );
if ( searchForm && searchInput ) {
const typeahead = require( './typeahead.js' );
typeahead.init( searchForm, searchInput );
}
}
initSearchLoader();

View file

@ -0,0 +1,113 @@
@import '../variables.less';
@import '../mixins.less';
.citizen-typeahead {
position: absolute;
z-index: 101;
top: 100%;
display: none;
width: 100%;
box-sizing: border-box;
border: 1px solid var( --border-color-base--lighter );
margin: 0;
background: var( --background-color-dp-08 );
border-radius: 0 0 @border-radius-large @border-radius-large;
.boxshadow(4);
&-suggestion {
padding-top: 6px;
padding-bottom: 6px;
&__thumbnail {
overflow: hidden;
height: 60px;
background-color: var( --background-color-framed );
border-radius: @border-radius-small;
img,
source {
object-fit: cover;
}
}
&__text {
overflow: hidden;
white-space: nowrap;
}
&__title {
color: var( --color-base--emphasized );
font-weight: 700;
&__highlight {
color: var( --color-base--subtle );
}
}
&__description {
margin-top: 0.1rem;
color: var( --color-base--subtle );
font-size: @content-caption-size;
}
&__title,
&__description {
overflow: hidden;
text-overflow: ellipsis;
}
}
&-footer {
padding-top: @border-radius-large;
padding-bottom: @border-radius-large;
border-top: 1px solid var( --border-color-base );
border-radius: 0 0 @border-radius-large @border-radius-large;
font-size: @content-monospace-size;
font-weight: 450;
&__icon {
height: var( --size-icon );
}
}
&-option {
&--active {
background-color: var( --background-color-primary--hover );
}
}
a {
display: flex;
align-items: center;
padding-right: 12px;
padding-left: 12px;
color: var( --color-base );
}
picture {
width: 100%;
max-width: 70px;
margin-right: 12px;
img,
source {
width: inherit;
height: inherit;
}
}
&--expanded {
display: block;
}
// Use to hide rounded corner from searchbox
&:before {
position: absolute;
width: var( --width-search-bar );
height: @border-radius-small;
margin-top: -@border-radius-small;
margin-left: -1px;
background: var( --background-color-dp-08 );
content: '';
}
}

View file

@ -0,0 +1,27 @@
{{!
string msg-citizen-search-fulltext The label on the fulltext search in the typeahead.
}}
<ol id="searchform-suggestions" class="citizen-typeahead" role="listbox">
<li role="option">
<a href="" id="searchform-suggestions-footer" class="citizen-typeahead-footer">
<picture class="citizen-typeahead-footer__icon">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMCAyMCI+DQoJPHBhdGggZmlsbD0iIzM2YyIgZD0iTTcuNSAxM2MzLjA0IDAgNS41LTIuNDYgNS41LTUuNVMxMC41NCAyIDcuNSAyIDIgNC40NiAyIDcuNSA0LjQ2IDEzIDcuNSAxM3ptNC41NS40NkE3LjQzMiA3LjQzMiAwIDAgMSA3LjUgMTVDMy4zNiAxNSAwIDExLjY0IDAgNy41UzMuMzYgMCA3LjUgMEMxMS42NCAwIDE1IDMuMzYgMTUgNy41YzAgMS43MS0uNTcgMy4yOS0xLjU0IDQuNTVsNi40OSA2LjQ5LTEuNDEgMS40MS02LjQ5LTYuNDl6Ii8+DQoJPHBhdGggZmlsbD0iIzM2YyIgZD0iTTQgNWg3djFINHptMCAyaDd2MUg0em0wIDJoNS40NDR2MUg0eiIvPg0KPC9zdmc+DQo="/>
</picture>
<div class="citizen-typeahead-footer__text">{{{msg-citizen-search-fulltext}}}</div>
</a>
</li>
<template id="citizen-typeahead-suggestion-template">
<li role="option">
<a href="" class="citizen-typeahead-suggestion">
<picture class="citizen-typeahead-suggestion__thumbnail">
<img src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4NCjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB2aWV3Qm94PSIwIDAgNTYgNTYiPg0KCTxwYXRoIGZpbGw9IiNlYWVjZjAiIGQ9Ik0wIDBoNTZ2NTZIMCIvPg0KCTxwYXRoIGZpbGw9IiM3Mjc3N2QiIGQ9Ik0zNi40IDEzLjVIMTcuOHYyNC45YzAgMS40LjkgMi4zIDIuMyAyLjNoMTguN3YtMjVjLjEtMS40LTEtMi4yLTIuNC0yLjJ6TTMwLjIgMTdoNS4xdjYuNGgtNS4xVjE3em0tOC44IDBoNnYxLjhoLTZWMTd6bTAgNC42aDZ2MS44aC02di0xLjh6bTAgMTUuNXYtMS44aDEzLjh2MS44SDIxLjR6bTEzLjgtNC41SDIxLjR2LTEuOGgxMy44djEuOHptMC00LjdIMjEuNHYtMS44aDEzLjh2MS44eiIvPg0KPC9zdmc+DQo="/>
</picture>
<div class="citizen-typeahead-suggestion__text">
<div class="citizen-typeahead-suggestion__title"></div>
<div class="citizen-typeahead-suggestion__description"></div>
</div>
</a>
</li>
</template>
</ol>

View file

@ -0,0 +1,308 @@
const config = require( './config.json' );
const activeIndex = {
index: -1,
max: config.wgCitizenMaxSearchResults + 1,
setMax: function ( x ) {
this.max = x + 1;
},
increment: function ( i ) {
this.index += i;
if ( this.index < 0 ) {
this.setIndex( this.max - 1 );
} // Index reaches top
if ( this.index === this.max ) {
this.setIndex( 0 );
} // Index reaches bottom
return this.index;
},
setIndex: function ( i ) {
if ( i <= this.max - 1 ) {
this.index = i;
}
return this.index;
},
clear: function () {
this.setIndex( -1 );
}
};
let typeahead, searchInput;
/*
* @param {HTMLElement} element
* @return {void}
*/
function toggleActive( element ) {
const typeaheadItems = typeahead.querySelectorAll( 'li > a' ),
activeClass = 'citizen-typeahead-option--active';
/* eslint-disable mediawiki/class-doc */
for ( let i = 0; i < typeaheadItems.length; i++ ) {
if ( element !== typeaheadItems[ i ] ) {
typeaheadItems[ i ].classList.remove( activeClass );
} else {
if ( element.classList.contains( activeClass ) ) {
element.classList.remove( activeClass );
} else {
element.classList.add( activeClass );
searchInput.setAttribute( 'aria-activedescendant', element.id );
activeIndex.setIndex( i );
}
}
}
/* eslint-enable mediawiki/class-doc */
}
/**
* Keyboard events: up arrow, down arrow and enter.
* moves the 'active' suggestion up and down.
*
* @param {Event} event
*/
function keyboardEvents( event ) {
if ( event.defaultPrevented ) {
return; // Do nothing if the event was already processed
}
// Is children slower?
const typeaheadItems = typeahead.querySelectorAll( 'li > a' );
if ( event.key === 'ArrowDown' || event.key === 'ArrowUp' ) {
if ( event.key === 'ArrowDown' ) {
activeIndex.increment( 1 );
} else {
activeIndex.increment( -1 );
}
toggleActive( typeaheadItems[ activeIndex.index ] );
}
if ( event.key === 'Enter' && typeaheadItems[ activeIndex.index ] ) {
event.preventDefault();
typeaheadItems[ activeIndex.index ].click();
}
}
/*
* Bind mouseenter and mouseleave event to reproduce mouse hover event
*
* @param {HTMLElement} element
* @return {void}
*/
function bindMouseHoverEvent( element ) {
element.addEventListener( 'mouseenter', ( event ) => {
toggleActive( event.currentTarget );
} );
element.addEventListener( 'mouseleave', ( event ) => {
toggleActive( event.currentTarget );
} );
}
/**
* Remove all existing suggestions from typeahead
*
* @return {void}
*/
function clearSuggestions() {
const typeaheadItems = typeahead.children,
nonSuggestionCount = 2;
if ( typeaheadItems.length > nonSuggestionCount ) {
// Splice would be cleaner but it is slower (?)
const fragment = new DocumentFragment(),
nonSuggestionItems = [ ...typeaheadItems ].slice(
typeaheadItems.length - nonSuggestionCount, typeaheadItems.length
);
nonSuggestionItems.forEach( ( item ) => {
fragment.append( item );
} );
typeahead.replaceChildren( fragment );
}
// Remove loading animation
searchInput.parentNode.classList.remove( 'search-form__loading' );
searchInput.setAttribute( 'aria-activedescendant', '' );
activeIndex.clear();
}
/**
* Fetch suggestions from API and render the suggetions in HTML
*
* @param {string} searchQuery
* @return {void}
*/
function getSuggestions( searchQuery ) {
const renderSuggestions = ( results ) => {
const prefix = 'citizen-typeahead-suggestion',
template = document.getElementById( prefix + '-template' ),
fragment = document.createDocumentFragment(),
suggestionLinkPrefix = config.wgScriptPath + '/index.php?title=Special:Search&search=',
sanitizedSearchQuery = mw.html.escape( mw.util.escapeRegExp( searchQuery ) ),
regex = new RegExp( sanitizedSearchQuery, 'i' );
// Maybe there is a cleaner way?
// Maybe there is a faster way compared to multiple querySelector?
// Should I just use regular for loop for faster performance?
results.forEach( ( result ) => {
const suggestion = template.content.cloneNode( true ),
suggestionLink = suggestion.querySelector( '.' + prefix ),
suggestionThumbnail = suggestion.querySelector( '.' + prefix + '__thumbnail img' ),
suggestionTitle = suggestion.querySelector( '.' + prefix + '__title' ),
suggestionDescription = suggestion.querySelector( '.' + prefix + '__description' );
// Give <a> element a unique ID
suggestionLink.id = prefix + '-' + result.id;
suggestionLink.setAttribute( 'href', suggestionLinkPrefix + result.title );
if ( result.thumbnail ) {
suggestionThumbnail.setAttribute( 'src', result.thumbnail );
}
// Highlight title
suggestionTitle.innerHTML = result.title.replace( regex, '<span class="' + prefix + '__title__highlight">$&</span>' );
suggestionDescription.textContent = result.description;
fragment.append( suggestion );
} );
typeahead.prepend( fragment );
};
// Attach mouseenter events to newly created suggestions
const attachMouseListener = () => {
const suggestions = typeahead.querySelectorAll( '.citizen-typeahead-suggestion' );
suggestions.forEach( ( suggestion ) => {
bindMouseHoverEvent( suggestion );
} );
};
// Add loading animation
searchInput.parentNode.classList.add( 'search-form__loading' );
/* eslint-disable-next-line compat/compat */
const controller = new AbortController(),
abortFetch = () => {
controller.abort();
};
const gateway = require( './gateway/gateway.js' ),
getResults = gateway.getResults( searchQuery, controller );
// Abort fetch if the input is detected
// So that fetch request won't be queued up
searchInput.addEventListener( 'input', abortFetch, { once: true } );
getResults.then( ( results ) => {
searchInput.removeEventListener( 'input', abortFetch );
clearSuggestions();
if ( results !== null ) {
renderSuggestions( results );
attachMouseListener();
}
activeIndex.setMax( results.length );
} ).catch( ( error ) => {
searchInput.removeEventListener( 'input', abortFetch );
searchInput.parentNode.classList.remove( 'search-form__loading' );
// User can trigger the abort when the fetch event is pending
// There is no need for an error
if ( error.name !== 'AbortError' ) {
const message = 'Uh oh, a wild error appears! ' + error;
throw new Error( message );
}
} );
}
/**
* Update the typeahead element
*
* @param {Object} messages
* @return {void}
*/
function updateTypeahead( messages ) {
const searchQuery = searchInput.value,
footer = document.getElementById( 'searchform-suggestions-footer' ),
footerText = footer.querySelector( '.citizen-typeahead-footer__text' ),
fullTextUrl = config.wgScriptPath + '/index.php?title=Special:Search&fulltext=1&search=';
if ( searchQuery.length > 0 ) {
const footerQuery = mw.html.escape( searchQuery );
footerText.innerHTML = messages.fulltext + ' <strong>' + footerQuery + '</strong>';
footerQuery.textContent = searchQuery;
footer.setAttribute( 'href', fullTextUrl + searchQuery );
getSuggestions( searchQuery );
} else {
footerText.textContent = messages.empty;
footer.setAttribute( 'href', fullTextUrl );
clearSuggestions();
}
}
/**
* @param {HTMLElement} searchForm
* @param {HTMLInputElement} input
* @return {void}
*/
function initTypeahead( searchForm, input ) {
const expandedClass = 'citizen-typeahead--expanded',
messages = {
empty: mw.message( 'citizen-search-fulltext-empty' ).text(),
fulltext: mw.message( 'citizen-search-fulltext' ).text()
},
template = mw.template.get(
'skins.citizen.search',
'resources/skins.citizen.search/templates/typeahead.mustache'
),
data = {
'msg-citizen-search-fulltext': messages.empty
};
const onFocus = function () {
searchForm.setAttribute( 'aria-expanded', 'true' );
/* eslint-disable-next-line mediawiki/class-doc */
typeahead.classList.add( expandedClass );
searchInput.addEventListener( 'keydown', keyboardEvents );
};
const onBlur = function () {
searchForm.setAttribute( 'aria-expanded', 'false' );
searchInput.setAttribute( 'aria-activedescendant', '' );
/* eslint-disable-next-line mediawiki/class-doc */
typeahead.classList.remove( expandedClass );
searchInput.removeEventListener( 'keydown', keyboardEvents );
};
// Make them accessible outside of the function
typeahead = template.render( data ).get()[ 1 ];
searchInput = input;
searchForm.append( typeahead );
searchForm.setAttribute( 'aria-owns', 'searchform-suggestions' );
searchInput.setAttribute( 'aria-autocomplete', 'list' );
searchInput.setAttribute( 'aria-controls', 'searchform-suggestions' );
const footer = document.getElementById( 'searchform-suggestions-footer' );
bindMouseHoverEvent( footer );
// Since searchInput is focused before the event listener is set up
onFocus();
searchInput.addEventListener( 'focus', onFocus );
searchInput.addEventListener( 'blur', onBlur );
// Run once in case there is searchQuery before eventlistener is attached
if ( searchInput.value.length > 0 ) {
updateTypeahead( messages );
}
searchInput.addEventListener( 'input', () => {
mw.util.debounce( 100, updateTypeahead( messages ) );
} );
}
module.exports = {
init: initTypeahead
};

View file

@ -1,206 +0,0 @@
//
// Citizen - Search styles
// https://starcitizen.tools
//
@import '../variables.less';
@import '../mixins.less';
#typeahead-suggestions {
position: absolute;
z-index: 90;
top: 38px;
}
.suggestions-dropdown {
display: flex; // Needed to show margin
overflow: auto;
width: var( --width-search-bar );
max-width: calc( ~'100vw -'@icon-box-size + @icon-padding * 2 + @margin-side );
max-height: ~'calc( 100vh - var( --height-header ) + 10px )';
flex-direction: column;
padding-top: 4px;
background: var( --background-color-dp-08 );
border-radius: 0 0 @border-radius-large @border-radius-large;
.boxshadow(4);
}
.suggestion {
&-link {
display: flex;
align-items: center;
padding: 8px 20px;
&.active {
background-color: var( --background-color-primary--hover );
&:active {
background-color: var( --background-color-primary--active );
}
}
}
&-text {
margin-left: 15px;
}
&-title {
display: inline-block; // so that the margin does not occupy space
margin: 0 0 0.78rem * @content-scaling 0;
font-size: @content-h6-size;
line-height: 1.872rem * @content-scaling;
}
&-highlight {
color: var( --color-base--subtle );
font-style: normal;
}
&-description {
margin: 0;
color: var( --color-base--subtle );
font-family: var( --font-family-base );
font-size: @content-caption-size;
line-height: 1.43rem * @content-scaling;
}
&-thumbnail {
width: 70px;
min-width: 70px; // so it won't be squeezed by the content text
height: 60px;
background-color: var( --background-color-framed );
background-position: center center;
background-repeat: no-repeat;
background-size: cover;
border-radius: @border-radius-small;
}
}
#suggestion {
&-link {
&-first {
margin-top: 8px;
}
&-last {
margin-bottom: 8px;
}
}
&-special {
display: flex;
align-items: center;
padding: 1rem;
border-top: 1px solid var( --border-color-base );
border-radius: 0 0 @border-radius-large @border-radius-large;
color: var( --color-base );
&-icon {
width: 20px;
min-width: 20px;
height: 20px;
margin: 0 14px 0 10px;
background-repeat: no-repeat;
}
&-text {
overflow: hidden;
padding: 5px 0; // make it looks more center aligned
font-family: var( --font-family-base );
font-size: @content-caption-size;
font-weight: 450;
text-overflow: ellipsis; // handle overflow
.suggestion-highlight {
color: var( --color-base--emphasized );
font-weight: bold;
}
}
&:hover {
background-color: var( --background-color-primary );
}
}
}
/* using element selector to override default anchor styles */
a.suggestion-link:hover {
text-decoration: none;
}
/**
* Loading indicator for search widget
* Based on Vector
*
* By adding a class on the parent search form
* <div id="searchform" class="search-form__loader"></div>
* A pseudo element is added via ':after' that adds the loading indicator.
*
* After appearing for more than a second, a progress-bar animation appears
* above the loading indicator.
*
**/
#searchform.search-form__loading:after {
--height-search-bar__progress-bar: @height-search-bar__progress-bar;
position: absolute;
z-index: 999;
top: 100%;
// Position loader below the input.
display: block;
overflow: hidden;
width: 100%;
// Hide text in case it extends beyond the input.
height: var( --height-search-bar__progress-bar );
// Ensure it doesn't extend beyond the input.
box-sizing: border-box;
// Overlay the form
margin-top: -100%;
// Animates the progress-bar.
animation: search-bar__progress-bar
/* duration */ 1200ms
/* timing function */ linear
/* delay */ 1000ms
/* iteration count */ infinite
/* fill direction */ alternate;
// Add a progress-bar to the loading indicator,
// but only show it animating after 1 second of loading.
background: /*image*/ linear-gradient( 90deg, var( --color-primary ) 0%, var( --color-primary ) 100% )
/* position / size*/ -10% 0 ~'/' 0 var( --height-search-bar__progress-bar )
/* repeat */ no-repeat,transparent;
// Align style with the form
border-radius: 0 0 @border-radius-large @border-radius-large;
// Placeholder text
content: 'loading';
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes search-bar__progress-bar {
0% {
background-position: -10% 0;
background-size: 0 var( --height-search-bar__progress-bar );
}
30% {
background-position: -10% 0;
background-size: 30% var( --height-search-bar__progress-bar );
}
70% {
background-position: 110% 0;
background-size: 30% var( --height-search-bar__progress-bar );
}
100% {
background-position: 110% 0;
background-size: 0 var( --height-search-bar__progress-bar );
}
}
@media only screen and ( max-width: @width-search-bar ) {
.suggestions-dropdown {
position: fixed;
left: 0;
max-width: 100vw;
}
}

View file

@ -273,3 +273,71 @@
}
}
}
/**
* Loading indicator for searchbox
* In core so that loading animation can appear while the search module loads
* Based on Vector
*
* By adding a class on the parent search form
* <div id="searchform" class="search-form__loader"></div>
* A pseudo element is added via ':after' that adds the loading indicator.
*
* After appearing for more than a second, a progress-bar animation appears
* above the loading indicator.
*
**/
#searchform.search-form__loading:after {
--height-search-bar__progress-bar: @height-search-bar__progress-bar;
position: absolute;
z-index: 999;
top: 100%;
// Position loader below the input.
display: block;
overflow: hidden;
width: 100%;
// Hide text in case it extends beyond the input.
height: var( --height-search-bar__progress-bar );
// Ensure it doesn't extend beyond the input.
box-sizing: border-box;
// Animates the progress-bar.
animation: search-bar__progress-bar
/* duration */ 1200ms
/* timing function */ linear
/* delay */ 1000ms
/* iteration count */ infinite
/* fill direction */ alternate;
// Add a progress-bar to the loading indicator,
// but only show it animating after 1 second of loading.
background: /*image*/ linear-gradient( 90deg, var( --color-primary ) 0%, var( --color-primary ) 100% )
/* position / size*/ -10% 0 ~'/' 0 var( --height-search-bar__progress-bar )
/* repeat */ no-repeat,transparent;
// Align style with the form
border-radius: 0 0 @border-radius-large @border-radius-large;
// Placeholder text
content: 'loading';
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes search-bar__progress-bar {
0% {
background-position: -10% 0;
background-size: 0 var( --height-search-bar__progress-bar );
}
30% {
background-position: -10% 0;
background-size: 30% var( --height-search-bar__progress-bar );
}
70% {
background-position: 110% 0;
background-size: 30% var( --height-search-bar__progress-bar );
}
100% {
background-position: 110% 0;
background-size: 0 var( --height-search-bar__progress-bar );
}
}

View file

@ -45,12 +45,7 @@
"citizen-theme-toggle",
"citizen-footer-desc",
"citizen-footer-tagline",
"citizen-jumptotop",
"citizen-search-fulltext",
"prefs-citizen-theme-label",
"prefs-citizen-theme-option-auto",
"prefs-citizen-theme-option-light",
"prefs-citizen-theme-option-dark"
"citizen-jumptotop"
]
}
]
@ -81,9 +76,6 @@
"PreferenceHooks": {
"class": "Citizen\\Hooks\\PreferenceHooks"
},
"ResourceLoaderHooks": {
"class": "Citizen\\Hooks\\ResourceLoaderHooks"
},
"SkinHooks": {
"class": "Citizen\\Hooks\\SkinHooks"
},
@ -92,7 +84,6 @@
}
},
"Hooks": {
"ResourceLoaderGetConfigVars": "ResourceLoaderHooks",
"SkinPageReadyConfig": "SkinHooks",
"BeforePageDisplay": "SkinHooks",
"ThumbnailBeforeProduceHTML": "ThumbnailHooks",
@ -136,15 +127,6 @@
"features": [],
"styles": [ "resources/skins.citizen.styles.lazyload/skins.citizen.styles.lazyload.less" ]
},
"skins.citizen.styles.search": {
"class": "ResourceLoaderSkinModule",
"targets": [
"desktop",
"mobile"
],
"features": [],
"styles": [ "resources/skins.citizen.styles.search/skins.citizen.styles.search.less" ]
},
"skins.citizen.styles.sections": {
"class": "ResourceLoaderSkinModule",
"targets": [
@ -175,6 +157,10 @@
"skins.citizen.scripts": {
"packageFiles": [
"resources/skins.citizen.scripts/skin.js",
{
"name": "resources/skins.citizen.scripts/config.json",
"callback": "Citizen\\Hooks\\ResourceLoaderHooks::getCitizenResourceLoaderConfig"
},
"resources/skins.citizen.scripts/theme.js",
"resources/skins.citizen.scripts/search.js",
"resources/skins.citizen.scripts/checkboxHack.js"
@ -185,22 +171,6 @@
"resources/skins.citizen.scripts.lazyload/skins.citizen.scripts.lazyload.js"
]
},
"skins.citizen.scripts.search": {
"templates": [
"resources/skins.citizen.scripts.search/templates/suggestions.mustache"
],
"scripts": [
"resources/skins.citizen.scripts.search/underscore.partial.js",
"resources/skins.citizen.scripts.search/wm-typeahead.js",
"resources/skins.citizen.scripts.search/typeahead-init.js"
],
"dependencies": [
"mediawiki.api"
],
"messages": [
"citizen-search-fulltext"
]
},
"skins.citizen.scripts.toc": {
"scripts": [
"resources/skins.citizen.scripts.toc/skins.citizen.scripts.toc.js"
@ -216,6 +186,29 @@
"resources/skins.citizen.scripts.sections/skins.citizen.scripts.sections.js"
]
},
"skins.citizen.search": {
"templates": [
"resources/skins.citizen.search/templates/typeahead.mustache"
],
"styles": [
"resources/skins.citizen.search/skins.citizen.search.less"
],
"packageFiles": [
"resources/skins.citizen.search/main.js",
{
"name": "resources/skins.citizen.search/config.json",
"callback": "Citizen\\Hooks\\ResourceLoaderHooks::getCitizenSearchResourceLoaderConfig"
},
"resources/skins.citizen.search/typeahead.js",
"resources/skins.citizen.search/gateway/gateway.js",
"resources/skins.citizen.search/gateway/mwActionApi.js",
"resources/skins.citizen.search/gateway/mwRestApi.js"
],
"messages": [
"citizen-search-fulltext",
"citizen-search-fulltext-empty"
]
},
"skins.citizen.preferences": {
"templates": [
"resources/skins.citizen.preferences/templates/preferences.mustache"
@ -337,15 +330,6 @@
"lastmod": "resources/skins.citizen.icons.footer/history_white.svg"
}
},
"skins.citizen.icons.search": {
"class": "ResourceLoaderImageModule",
"selector": "{name}",
"useDataURI": false,
"images": {
".suggestion-thumbnail": "resources/skins.citizen.icons.search/noimage.svg",
"#suggestion-special-icon": "resources/skins.citizen.icons.search/searchfulltext.svg"
}
},
"skins.citizen.icons.sections": {
"class": "ResourceLoaderImageModule",
"selector": ".section-toggle",
@ -667,10 +651,10 @@
"descriptionmsg": "citizen-config-enablesearch",
"public": true
},
"SearchUseREST": {
"value": false,
"description": "Use REST API search endpoint instead of Action API",
"descriptionmsg": "citizen-config-searchuserest",
"SearchGateway": {
"value": "mwActionApi",
"description": "Which gateway to use for fetching search suggestion. Avaliable options: [mwActionApi|mwRestApi]",
"descriptionmsg": "citizen-config-searchgateway",
"public": true
},
"SearchDescriptionSource": {