mirror of
https://github.com/StarCitizenTools/mediawiki-skins-Citizen.git
synced 2024-11-24 06:24:22 +00:00
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:
parent
2987e23290
commit
b2bd79196d
|
@ -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
|
||||
|
|
|
@ -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}}",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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' ),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 |
|
@ -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 |
|
@ -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>
|
|
@ -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 ) );
|
|
@ -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;
|
||||
};
|
||||
};
|
|
@ -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
|
||||
};
|
||||
};
|
|
@ -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 {
|
||||
|
|
55
resources/skins.citizen.search/gateway/gateway.js
Normal file
55
resources/skins.citizen.search/gateway/gateway.js
Normal 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
|
||||
};
|
108
resources/skins.citizen.search/gateway/mwActionApi.js
Normal file
108
resources/skins.citizen.search/gateway/mwActionApi.js
Normal 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
|
||||
};
|
44
resources/skins.citizen.search/gateway/mwRestApi.js
Normal file
44
resources/skins.citizen.search/gateway/mwRestApi.js
Normal 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
|
||||
};
|
14
resources/skins.citizen.search/main.js
Normal file
14
resources/skins.citizen.search/main.js
Normal 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();
|
113
resources/skins.citizen.search/skins.citizen.search.less
Normal file
113
resources/skins.citizen.search/skins.citizen.search.less
Normal 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: '';
|
||||
}
|
||||
}
|
27
resources/skins.citizen.search/templates/typeahead.mustache
Normal file
27
resources/skins.citizen.search/templates/typeahead.mustache
Normal 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>
|
308
resources/skins.citizen.search/typeahead.js
Normal file
308
resources/skins.citizen.search/typeahead.js
Normal 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
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 );
|
||||
}
|
||||
}
|
||||
|
|
80
skin.json
80
skin.json
|
@ -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": {
|
||||
|
|
Loading…
Reference in a new issue