mediawiki-skins-Citizen/resources/skins.citizen.scripts/search.js
alistair3149 516ef3a1cb
feat(dropdown): revamp dropdown menu handling
Putting the dropdown content in the details elemenet have been inconsistent.
Transition are not working correctly and different browsers are not handling it well.
Furthermore, the previous implementation does not allow an always visible state of dropdown content.

Fixes: #882
2024-07-03 16:07:19 -04: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-details' );
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
};