mirror of
https://github.com/StarCitizenTools/mediawiki-skins-Citizen.git
synced 2024-11-28 08:10:45 +00:00
430 lines
14 KiB
JavaScript
430 lines
14 KiB
JavaScript
const SEARCH_LOADING_CLASS = 'citizen-loading';
|
|
|
|
// Config object from getCitizenSearchResourceLoaderConfig()
|
|
const config = require( './config.json' );
|
|
|
|
const searchPresults = require( './searchPresults.js' )();
|
|
const searchClient = require( './searchClient.js' )( config );
|
|
const searchHistory = require( './searchHistory.js' )( config );
|
|
const searchResults = require( './searchResults.js' )();
|
|
const searchQuery = require( './searchQuery.js' )();
|
|
|
|
const templateTypeaheadElement = require( './templates/TypeaheadElement.mustache' );
|
|
const templateTypeaheadPlaceholder = require( './templates/TypeaheadPlaceholder.mustache' );
|
|
const templateTypeaheadList = require( './templates/TypeaheadList.mustache' );
|
|
const templateTypeaheadListItem = require( './templates/TypeaheadListItem.mustache' );
|
|
|
|
const compiledTemplates = {};
|
|
|
|
const typeahead = {
|
|
/** @type {HTMLElement | undefined} */
|
|
element: undefined,
|
|
form: {
|
|
/** @type {HTMLFormElement | undefined} */
|
|
element: undefined,
|
|
isLoading: false,
|
|
init: function ( formEl ) {
|
|
const typeaheadFormElement = formEl;
|
|
this.element = typeaheadFormElement;
|
|
typeaheadFormElement.setAttribute( 'aria-owns', typeahead.element.id );
|
|
},
|
|
setLoadingState: function ( state ) {
|
|
this.element.classList.toggle( SEARCH_LOADING_CLASS, state );
|
|
this.isLoading = state;
|
|
}
|
|
},
|
|
input: {
|
|
/** @type {HTMLInputElement | undefined} */
|
|
element: undefined,
|
|
displayElement: undefined,
|
|
// Trigger update only when character is composed (e.g. CJK IME)
|
|
isComposing: false,
|
|
init: function ( inputEl ) {
|
|
const typeaheadInputElement = inputEl;
|
|
this.element = typeaheadInputElement;
|
|
|
|
const wrapper = document.createElement( 'div' );
|
|
wrapper.classList.add( 'citizen-typeahead-input-group' );
|
|
typeaheadInputElement.parentNode.insertBefore( wrapper, typeaheadInputElement );
|
|
|
|
const overlay = document.createElement( 'div' );
|
|
overlay.classList.add( 'citizen-typeahead-input-overlay' );
|
|
this.displayElement = document.createElement( 'span' );
|
|
this.displayElement.textContent = typeaheadInputElement.value;
|
|
this.displayElement.classList.add( 'citizen-typeahead-input-overlay-query' );
|
|
overlay.append( this.displayElement );
|
|
|
|
typeaheadInputElement.classList.add( 'citizen-typeahead-input' );
|
|
typeaheadInputElement.setAttribute( 'aria-autocomplete', 'list' );
|
|
typeaheadInputElement.setAttribute( 'aria-controls', typeahead.element.id );
|
|
|
|
wrapper.append( overlay, typeaheadInputElement );
|
|
typeaheadInputElement.addEventListener( 'focus', this.onFocus );
|
|
|
|
const isVisible = typeaheadInputElement.offsetWidth > 0 ||
|
|
typeaheadInputElement.offsetHeight > 0;
|
|
const isFocusable = !typeaheadInputElement.disabled && !typeaheadInputElement.readOnly;
|
|
|
|
if ( isVisible && isFocusable ) {
|
|
requestAnimationFrame( () => {
|
|
typeaheadInputElement.focus();
|
|
} );
|
|
}
|
|
},
|
|
onCompositionstart: function () {
|
|
typeahead.input.element.addEventListener( 'compositionend', typeahead.input.onCompositionend );
|
|
typeahead.input.isComposing = true;
|
|
},
|
|
onCompositionend: function () {
|
|
typeahead.input.isComposing = false;
|
|
typeahead.input.element.dispatchEvent( new Event( 'input' ) );
|
|
},
|
|
onFocus: function () {
|
|
const typeaheadInputElement = typeahead.input.element;
|
|
// Refresh the typeahead since the query will be emptied when blurred
|
|
typeahead.afterSearchQueryInput();
|
|
typeahead.form.element.parentElement.classList.add( 'citizen-search__card--expanded' );
|
|
// FIXME: Should probably clean up this somehow
|
|
typeahead.element.addEventListener( 'click', typeahead.onClick );
|
|
typeaheadInputElement.addEventListener( 'keydown', typeahead.input.onKeydown );
|
|
typeaheadInputElement.addEventListener( 'input', typeahead.input.onInput );
|
|
typeaheadInputElement.addEventListener( 'blur', typeahead.onBlur );
|
|
},
|
|
onInput: function () {
|
|
const typeaheadInputElement = typeahead.input.element;
|
|
typeahead.input.displayElement.textContent = typeaheadInputElement.value;
|
|
typeaheadInputElement.addEventListener( 'compositionstart', typeahead.input.onCompositionstart );
|
|
if ( typeahead.input.isComposing !== true ) {
|
|
mw.util.debounce( typeahead.afterSearchQueryInput(), 100 );
|
|
}
|
|
},
|
|
onKeydown: function ( event ) {
|
|
if ( event.defaultPrevented ) {
|
|
return; // Do nothing if the event was already processed
|
|
}
|
|
|
|
/* Moves the active item up and down */
|
|
if ( event.key === 'ArrowDown' || event.key === 'ArrowUp' ) {
|
|
event.preventDefault();
|
|
if ( event.key === 'ArrowDown' ) {
|
|
typeahead.items.increment( 1 );
|
|
} else {
|
|
typeahead.items.increment( -1 );
|
|
}
|
|
typeahead.items.toggle( typeahead.items.elements[ typeahead.items.index ] );
|
|
}
|
|
|
|
/* Enter to click on the active item */
|
|
const link = typeahead.items.elements[ typeahead.items.index ];
|
|
if ( event.key === 'Enter' && link && link instanceof HTMLAnchorElement ) {
|
|
event.preventDefault();
|
|
link.click();
|
|
}
|
|
}
|
|
},
|
|
items: {
|
|
/** @type {NodeList | undefined} */
|
|
elements: undefined,
|
|
index: -1,
|
|
max: 0,
|
|
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;
|
|
},
|
|
clearIndex: function () {
|
|
this.setIndex( -1 );
|
|
},
|
|
/**
|
|
* Sets 'citizen-typeahead__item--active' class on the element
|
|
*
|
|
* @param {HTMLElement} item
|
|
*/
|
|
toggle: function ( item ) {
|
|
this.elements.forEach( ( element, index ) => {
|
|
if ( item !== element ) {
|
|
delete element.dataset.mwTypeaheadSelected;
|
|
} else {
|
|
if ( item.dataset.mwTypeaheadSelected ) {
|
|
delete item.dataset.mwTypeaheadSelected;
|
|
} else {
|
|
item.dataset.mwTypeaheadSelected = '';
|
|
typeahead.input.element.setAttribute( 'aria-activedescendant', item.id );
|
|
this.setIndex( index );
|
|
}
|
|
}
|
|
} );
|
|
},
|
|
// So that mouse hover events are the same as keyboard hover events
|
|
bindMouseHoverEvent: function () {
|
|
this.elements.forEach( ( element ) => {
|
|
element.addEventListener( 'mouseenter', ( event ) => {
|
|
this.toggle( event.currentTarget );
|
|
} );
|
|
element.addEventListener( 'mouseleave', ( event ) => {
|
|
this.toggle( event.currentTarget );
|
|
} );
|
|
} );
|
|
},
|
|
set: function () {
|
|
const typeaheadElement = typeahead.element;
|
|
this.elements = typeaheadElement.querySelectorAll( '.citizen-typeahead-group[data-mw-typeahead-keyboard-navigation] .citizen-typeahead-list-item-link' );
|
|
this.bindMouseHoverEvent();
|
|
this.setMax( this.elements.length );
|
|
}
|
|
},
|
|
onBlur: function ( event ) {
|
|
const typeaheadElement = typeahead.element;
|
|
const typeaheadInputElement = typeahead.input.element;
|
|
if ( !typeaheadElement.contains( event.relatedTarget ) ) {
|
|
// HACK: On Safari, users are unable to click any links because the blur
|
|
// event dismiss the links before it is clicked. This should fix it.
|
|
setTimeout( () => {
|
|
typeahead.form.element.parentElement.classList.remove( 'citizen-search__card--expanded' );
|
|
typeaheadInputElement.setAttribute( 'aria-activedescendant', '' );
|
|
typeaheadElement.removeEventListener( 'click', typeahead.onClick );
|
|
typeaheadInputElement.removeEventListener( 'keydown', typeahead.input.onKeydown );
|
|
// input listener need to stay on to make clear button works
|
|
// typeaheadInputElement.removeEventListener( 'input', typeahead.input.onInput );
|
|
typeaheadInputElement.removeEventListener( 'compositionstart', typeahead.input.onCompositionstart );
|
|
typeaheadInputElement.removeEventListener( 'compositionend', typeahead.input.onCompositionend );
|
|
typeaheadInputElement.removeEventListener( 'blur', this.onBlur );
|
|
}, 10 );
|
|
}
|
|
},
|
|
onClick: function ( event ) {
|
|
// Extra safety so closest won't tranverse out of the typeahead
|
|
if ( typeahead.element.contains( event.target ) ) {
|
|
const link = event.target.closest( '.citizen-typeahead-list-item-link' );
|
|
// Early exit if target is not a link
|
|
if ( !link ) {
|
|
return;
|
|
}
|
|
|
|
const group = event.target.closest( '.citizen-typeahead-group' );
|
|
|
|
// Save to history on click
|
|
const historyType = group.dataset.mwTypeaheadHistoryValue;
|
|
if ( historyType ) {
|
|
let historyText;
|
|
if ( historyType === 'query' ) {
|
|
historyText = searchQuery.value;
|
|
} else {
|
|
const historyTextEl = link.querySelector( `.citizen-typeahead-list-item-${ historyType }` );
|
|
if ( historyTextEl && historyTextEl.innerText ) {
|
|
historyText = historyTextEl.innerText;
|
|
}
|
|
}
|
|
if ( historyText ) {
|
|
searchHistory.add( historyText );
|
|
}
|
|
}
|
|
}
|
|
},
|
|
updateSearchClient: function () {
|
|
const typeaheadInputElement = typeahead.input.element;
|
|
searchClient.setActive( config.wgCitizenSearchGateway );
|
|
|
|
// Search command experiement
|
|
if ( typeaheadInputElement.value.startsWith( '/' ) ) {
|
|
const command = typeaheadInputElement.value.split( ' ' )[ 0 ].slice( 1 );
|
|
if ( command.length > 0 ) {
|
|
const searchClientData = searchClient.getData( 'command', command );
|
|
// Multi-search clients experiment
|
|
if ( searchClientData ) {
|
|
searchClient.setActive( searchClientData.id );
|
|
searchQuery.remove( `/${ command } ` );
|
|
}
|
|
}
|
|
}
|
|
return Promise.resolve( `Search client updated to ${ searchClient.active.id }.` );
|
|
},
|
|
updateSearchQuery: function () {
|
|
const currentQuery = typeahead.input.element.value;
|
|
if ( searchQuery.value === currentQuery ) {
|
|
return Promise.reject( `Search query has not changed: ${ searchQuery.value }.` );
|
|
}
|
|
|
|
searchQuery.setValue( currentQuery );
|
|
|
|
typeahead.updateSearchClient();
|
|
|
|
// TODO: Merge this with the search client command and put this somewhere else
|
|
const replaceRules = [
|
|
{
|
|
startWith: '{{',
|
|
pattern: /{{(.[^}]*)}?}?/,
|
|
replace: 'Template:$1',
|
|
clients: [ 'mwActionApi', 'mwRestApi' ]
|
|
},
|
|
{
|
|
startWith: '[[',
|
|
pattern: /\[\[(.[^\]]*)\]?\]?/,
|
|
replace: '$1',
|
|
clients: [ 'mwActionApi', 'mwRestApi' ]
|
|
}
|
|
];
|
|
|
|
replaceRules.forEach( ( rule ) => {
|
|
if ( rule.clients.includes( searchClient.active.id ) && searchQuery.value.startsWith( rule.startWith ) ) {
|
|
searchQuery.replace( rule.pattern, rule.replace );
|
|
}
|
|
} );
|
|
|
|
return Promise.resolve( `Search query updated to ${ searchQuery.value }.` );
|
|
},
|
|
afterSearchQueryInput: function () {
|
|
typeahead.updateSearchQuery().then( updateTypeaheadItems )
|
|
.catch( () => {
|
|
// Don't do anything if search query has not changed.
|
|
} );
|
|
},
|
|
init: function ( formEl, inputEl ) {
|
|
// Compile Mustache templates
|
|
// TODO: Find better way to handle this
|
|
this.mustacheCompiler = mw.template.getCompiler( 'mustache' );
|
|
Object.assign( compiledTemplates, {
|
|
TypeaheadElement: this.mustacheCompiler.compile( templateTypeaheadElement ),
|
|
TypeaheadPlaceholder: this.mustacheCompiler.compile( templateTypeaheadPlaceholder ),
|
|
TypeaheadList: this.mustacheCompiler.compile( templateTypeaheadList ),
|
|
TypeaheadListItem: this.mustacheCompiler.compile( templateTypeaheadListItem )
|
|
} );
|
|
|
|
const data = {
|
|
'data-placeholder': { hidden: true },
|
|
'array-lists': [
|
|
{ type: 'action', class: 'citizen-typeahead-group--chips', hidden: true, historyValue: 'query' },
|
|
{ type: 'history', hidden: true, keyboardNavigation: true },
|
|
{ type: 'page', hidden: true, keyboardNavigation: true, historyValue: 'title' }
|
|
]
|
|
};
|
|
const partials = {
|
|
TypeaheadPlaceholder: compiledTemplates.TypeaheadPlaceholder,
|
|
TypeaheadList: compiledTemplates.TypeaheadList
|
|
};
|
|
this.element = compiledTemplates.TypeaheadElement.render( data, partials ).get()[ 0 ];
|
|
|
|
formEl.after( this.element );
|
|
|
|
this.form.init( formEl );
|
|
this.input.init( inputEl );
|
|
|
|
searchHistory.init();
|
|
searchResults.init();
|
|
|
|
searchPresults.render( compiledTemplates );
|
|
// Init the value in case of undef error
|
|
typeahead.items.set();
|
|
|
|
// Run once in case there is searchQuery before eventlistener is attached
|
|
if ( this.input.element.value.length > 0 ) {
|
|
this.afterSearchQueryInput();
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Fetch suggestions from API and render the suggetions in HTML
|
|
*
|
|
*/
|
|
// eslint-disable-next-line es-x/no-async-functions
|
|
async function getSuggestions() {
|
|
const typeaheadInputElement = typeahead.input.element;
|
|
|
|
const renderSuggestions = ( results ) => {
|
|
const groupEl = document.getElementById( 'citizen-typeahead-group-page' );
|
|
const listEl = document.getElementById( 'citizen-typeahead-list-page' );
|
|
const placeholderEl = document.getElementById( 'citizen-typeahead-placeholder' );
|
|
|
|
if ( results.length > 0 ) {
|
|
// TODO: This should be a generic method
|
|
listEl.outerHTML = searchResults.getResultsHTML(
|
|
results,
|
|
searchQuery.valueHtml,
|
|
compiledTemplates
|
|
);
|
|
groupEl.hidden = false;
|
|
placeholderEl.innerHTML = '';
|
|
placeholderEl.hidden = true;
|
|
} else {
|
|
// Update placeholder with no result content
|
|
listEl.innerHTML = '';
|
|
groupEl.hidden = true;
|
|
placeholderEl.innerHTML = searchResults.getPlaceholderHTML( searchQuery.valueHtml, compiledTemplates );
|
|
placeholderEl.hidden = false;
|
|
}
|
|
|
|
typeahead.form.setLoadingState( false );
|
|
typeahead.items.set();
|
|
};
|
|
|
|
// Add loading animation
|
|
typeahead.form.setLoadingState( true );
|
|
|
|
const { abort, fetch } = searchResults.fetch( searchQuery.value, searchClient.active.client );
|
|
|
|
const inputEventListener = () => {
|
|
abort();
|
|
typeaheadInputElement.removeEventListener( 'input', inputEventListener );
|
|
};
|
|
typeaheadInputElement.addEventListener( 'input', inputEventListener, { once: true } );
|
|
|
|
try {
|
|
const response = await fetch;
|
|
renderSuggestions( response.results );
|
|
} catch ( error ) {
|
|
typeahead.form.setLoadingState( false );
|
|
// 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
|
|
*
|
|
*/
|
|
function updateTypeaheadItems() {
|
|
typeahead.input.element.setAttribute( 'aria-activedescendant', '' );
|
|
typeahead.items.clearIndex();
|
|
|
|
if ( searchQuery.isValid ) {
|
|
searchPresults.clear();
|
|
searchResults.render( searchQuery, compiledTemplates );
|
|
getSuggestions();
|
|
} else {
|
|
searchResults.clear();
|
|
searchPresults.render( compiledTemplates );
|
|
}
|
|
typeahead.items.set();
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLFormElement} formEl
|
|
* @param {HTMLInputElement} inputEl
|
|
*/
|
|
function initTypeahead( formEl, inputEl ) {
|
|
typeahead.init( formEl, inputEl );
|
|
}
|
|
|
|
module.exports = {
|
|
init: initTypeahead
|
|
};
|