/* 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 };