const config = require( './config.json' ), PREFIX = 'citizen-typeahead-suggestion', SEARCH_LOADING_CLASS = 'citizen-loading'; 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'; 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 ); } ); // TODO: Just use replaceChildren when browser support is >90% if ( typeof typeahead.replaceChildren !== 'undefined' ) { typeahead.replaceChildren( fragment ); } else { while ( typeahead.hasChildNodes() ) { typeahead.removeChild( typeahead.lastChild ); } typeahead.appendChild( fragment ); } } // Remove loading animation searchInput.parentNode.classList.remove( SEARCH_LOADING_CLASS ); 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 fragment = document.createDocumentFragment(), suggestionLinkPrefix = config.wgScriptPath + '/index.php?title=Special:Search&search='; const highlightTitle = ( text ) => { const sanitizedSearchQuery = mw.html.escape( mw.util.escapeRegExp( searchQuery ) ), regex = new RegExp( sanitizedSearchQuery, 'i' ); return text.replace( regex, '$&' ); }; const getSuggestionTitle = ( title, matchedTitle ) => { let html; // Result is a redirect // Show the redirect title and highlight it if ( matchedTitle && isRedirectUseful( title, matchedTitle ) ) { html = title + '' + mw.message( 'search-redirect', highlightTitle( matchedTitle ) ).plain() + ''; } else { // Highlight title html = highlightTitle( title ); } return html; }; const getRedirectLabel = ( title, matchedTitle ) => { // Check if the redirect is useful // See T303013 const isRedirectUseful = ( title, matchedTitle ) => { // Change to lowercase then remove space and dashes const cleanup = ( text ) => { return text.toLowerCase().replace( /-|\s/g, '' ); }; title = cleanup( title ); matchedTitle = cleanup( matchedTitle ); return !( title.includes( matchedTitle ) || matchedTitle.includes( title ) ); }; let html = ''; // Result is a redirect // Show the redirect title and highlight it if ( matchedTitle && isRedirectUseful( title, matchedTitle ) ) { html = '
' + /* Article redirect icon */ '' + /* Since we are matching that redirect title, it should be highlighted */ highlightTitle( matchedTitle ) + '
'; } return html; } results.forEach( ( result ) => { const suggestion = getMenuItem( { link: suggestionLinkPrefix + encodeURIComponent( result.key ), /* FIXME: Null check should happen at gateway */ thumbnail: result.thumbnail ?? '', title: highlightTitle( result.title ), label: getRedirectLabel( result.title, result.matchedTitle ), description: 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_LOADING_CLASS ); /* 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_LOADING_CLASS ); // 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 ); } } ); } /** * Generate menu item HTML using the existing template * * @param {Object} data * @return {HTMLElement|void} */ function getMenuItem( data ) { const template = document.getElementById( PREFIX + '-template' ); // Shouldn't happen but just to be safe if ( !( template instanceof HTMLTemplateElement ) ) { return; } const item = template.content.cloneNode( true ), link = item.querySelector( '.' + PREFIX ), thumbnail = item.querySelector( '.' + PREFIX + '__thumbnail img' ), title = item.querySelector( '.' + PREFIX + '__title' ), label = item.querySelector( '.' + PREFIX + '__label' ), description = item.querySelector( '.' + PREFIX + '__description' ); if ( data.link ) { link.setAttribute( 'href', data.link ); } if ( data.thumbnail ) { thumbnail.setAttribute( 'src', data.thumbnail ); } title.innerHTML = data.title ?? ''; label.innerHTML = data.label ?? ''; // Description only contains text description.textContent = data.description ?? ''; return item; } /** * 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 + ' ' + footerQuery + ''; 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 onBlur = ( event ) => { const focusIn = typeahead.contains( event.relatedTarget ); if ( !focusIn ) { // 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( () => { searchInput.setAttribute( 'aria-activedescendant', '' ); typeahead.classList.remove( expandedClass ); searchInput.removeEventListener( 'keydown', keyboardEvents ); searchInput.removeEventListener( 'blur', onBlur ); }, 10 ); } }; const onFocus = () => { // Refresh the typeahead since the query will be emptied when blurred updateTypeahead( messages ); typeahead.classList.add( expandedClass ); searchInput.addEventListener( 'keydown', keyboardEvents ); searchInput.addEventListener( 'blur', onBlur ); }; // 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 ); // 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 };