mediawiki-skins-Citizen/resources/skins.citizen.scripts/search.js
github-actions 26076514c1 ci: 👷 lint code to MediaWiki standards
Check commit and GitHub actions for more details
2024-06-07 19:01:54 +00:00

235 lines
6.7 KiB
JavaScript

/* Some of the functions are based on Vector */
/* ESLint does not like having class names as const */
const SEARCH_LOADING_CLASS = 'citizen-loading';
/**
* Loads the search module via `mw.loader.using` on the element's
* focus event. Or, if the element is already focused, loads the
* search module immediately.
* After the search module is loaded, executes a function to remove
* the loading indicator.
*
* @param {HTMLElement} element search input.
* @param {string} moduleName resourceLoader module to load.
* @param {function(): void} afterLoadFn function to execute after search module loads.
*/
function loadSearchModule( element, moduleName, afterLoadFn ) {
const requestSearchModule = () => {
mw.loader.using( moduleName, afterLoadFn );
element.removeEventListener( 'focus', requestSearchModule );
};
if ( document.activeElement === element ) {
requestSearchModule();
} else {
element.addEventListener( 'focus', requestSearchModule );
}
}
/**
* Event callback that shows or hides the loading indicator based on the event type.
* The loading indicator states are:
* 1. Show on input event (while user is typing)
* 2. Hide on focusout event (when user removes focus from the input )
* 3. Show when input is focused, if it contains a query. (in case user re-focuses on input)
*
* @param {Event} event
*/
function renderSearchLoadingIndicator( event ) {
const form = /** @type {HTMLElement} */ ( event.currentTarget ),
input = /** @type {HTMLInputElement} */ ( event.target );
if (
!( event.currentTarget instanceof HTMLElement ) ||
!( event.target instanceof HTMLInputElement )
) {
return;
}
if ( event.type === 'input' ) {
form.classList.add( SEARCH_LOADING_CLASS );
} else if ( event.type === 'focusout' ) {
form.classList.remove( SEARCH_LOADING_CLASS );
} else if ( event.type === 'focusin' && input.value.trim() ) {
form.classList.add( SEARCH_LOADING_CLASS );
}
}
/**
* Attaches or detaches the event listeners responsible for activating
* the loading indicator.
*
* @param {HTMLElement} element
* @param {boolean} attach
* @param {function(Event): void} eventCallback
*/
function setLoadingIndicatorListeners( element, attach, eventCallback ) {
/** @type { "addEventListener" | "removeEventListener" } */
const addOrRemoveListener = ( attach ? 'addEventListener' : 'removeEventListener' );
[ 'input', 'focusin', 'focusout' ].forEach( ( eventType ) => {
element[ addOrRemoveListener ]( eventType, eventCallback );
} );
if ( !attach ) {
element.classList.remove( SEARCH_LOADING_CLASS );
}
}
/**
* Manually focus on the input field if search toggle is clicked
*
* @param {HTMLDetailsElement} details
* @param {HTMLInputElement} input
* @return {void}
*/
function focusOnOpened( details, input ) {
if ( details.open ) {
input.focus();
} else {
input.blur();
}
}
/**
* Check if the element is a HTML form element or content editable
* This is to prevent trigger search box when user is typing on a textfield, input, etc.
*
* @param {HTMLElement} element
* @return {boolean}
*/
function isFormField( element ) {
if ( !( element instanceof HTMLElement ) ) {
return false;
}
const name = element.nodeName.toLowerCase();
const type = ( element.getAttribute( 'type' ) || '' ).toLowerCase();
return ( name === 'select' ||
name === 'textarea' ||
( name === 'input' && type !== 'submit' && type !== 'reset' && type !== 'checkbox' && type !== 'radio' ) ||
element.isContentEditable );
}
/**
* Manually toggle the details state when the keyboard button is SLASH is pressed.
*
* @param {Window} window
* @param {HTMLDetailsElement} details
* @param {HTMLInputElement} input
* @return {void}
*/
function bindOpenOnSlash( window, details, input ) {
const onExpandOnSlash = ( /** @type {KeyboardEvent} */ event ) => {
const isKeyPressed = () => {
// "/" key is standard on many sites
if ( event.key === '/' ) {
return true;
// "Alt" + "Shift" + "F" is the MW standard key
// Shift key might makes F key goes capital, so we need to make it lowercase
} else if ( event.altKey && event.shiftKey && event.key.toLowerCase() === 'f' ) {
return true;
} else {
return false;
}
};
if ( isKeyPressed() && !isFormField( event.target ) ) {
// Since Firefox quickfind interfere with this
event.preventDefault();
details.open = true;
focusOnOpened( details, input );
}
};
window.addEventListener( 'keydown', onExpandOnSlash, true );
}
/**
* Add clear button to search field when there are input value
*
* @param {HTMLInputElement} input
* @return {void}
*/
function renderSearchClearButton( input ) {
const
clearButton = document.createElement( 'span' ),
clearIcon = document.createElement( 'span' );
let hasClearButton = false;
clearButton.classList.add( 'citizen-search__clear', 'citizen-search__formButton' );
// TODO: Add i18n for the message below
// clearButton.setAttribute( 'aria-label', 'Clear search input' );
clearIcon.classList.add( 'citizen-ui-icon', 'mw-ui-icon-wikimedia-clear' );
clearButton.append( clearIcon );
clearButton.addEventListener( 'click', ( event ) => {
event.preventDefault();
clearButton.classList.add( 'hidden' );
input.value = '';
input.dispatchEvent( new Event( 'input' ) );
requestAnimationFrame( () => {
input.focus();
} );
} );
input.addEventListener( 'input', () => {
const value = input.value;
const shouldDisplay = value !== '';
clearButton.classList.toggle( 'hidden', !shouldDisplay );
if ( shouldDisplay && !hasClearButton ) {
input.after( clearButton );
}
hasClearButton = shouldDisplay;
} );
}
/**
* Initializes the search functionality for the Citizen search boxes.
*
* @param {Window} window
* @return {void}
*/
function initSearch( window ) {
const
searchModule = require( './config.json' ).wgCitizenSearchModule,
searchBoxes = document.querySelectorAll( '.citizen-search-box' );
if ( !searchBoxes.length ) {
return;
}
searchBoxes.forEach( ( searchBox ) => {
const
input = searchBox.querySelector( 'input[name="search"]' ),
isPrimarySearch = input && input.getAttribute( 'id' ) === 'searchInput';
if ( !input ) {
return;
}
// Set up primary search box interactions
if ( isPrimarySearch ) {
const details = document.getElementById( 'citizen-search' );
bindOpenOnSlash( window, details, input );
// Focus when toggled
details.addEventListener( 'toggle', () => {
focusOnOpened( details, input );
} );
}
renderSearchClearButton( input );
setLoadingIndicatorListeners( searchBox, true, renderSearchLoadingIndicator );
loadSearchModule( input, searchModule, () => {
setLoadingIndicatorListeners( searchBox, false, renderSearchLoadingIndicator );
} );
} );
}
module.exports = {
init: initSearch
};