diff --git a/bundlesize.config.json b/bundlesize.config.json index 823298b..5c873b5 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -5,7 +5,7 @@ }, { "resourceModule": "skins.vector.styles", - "maxSize": "9.0 kB" + "maxSize": "9.1 kB" }, { "resourceModule": "skins.vector.styles.responsive", diff --git a/i18n/en.json b/i18n/en.json index 4e39e67..e45a7fe 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -24,5 +24,6 @@ "vector-view-viewsource": "View source", "vector-jumptonavigation": "Jump to navigation", "vector-jumptosearch": "Jump to search", - "vector-more-actions": "More" + "vector-more-actions": "More", + "vector-search-loader": "Loading search suggestions" } diff --git a/i18n/qqq.json b/i18n/qqq.json index b350642..b6c1ec4 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -35,5 +35,6 @@ "vector-view-viewsource": "Tab label in the Vector skin.\n{{Identical|View source}}", "vector-jumptonavigation": "Accessibility link for jumping to the navigation links. Visually hidden by default.\n\nSee also\n* {{msg-mw|Navigation}}", "vector-jumptosearch": "Accessibility link for jumping to the site search. Visually hidden by default.\n\nSee also\n* {{msg-mw|Search}}", - "vector-more-actions": "Label in the Vector skin's menu for the less-important or rarer actions which are not shown as tabs (like moving the page, or for sysops deleting or protecting the page), as well as (for users with a narrow viewing window in their browser) the less-important tab actions which the user's browser is unable to fit in. {{Identical|More}}" + "vector-more-actions": "Label in the Vector skin's menu for the less-important or rarer actions which are not shown as tabs (like moving the page, or for sysops deleting or protecting the page), as well as (for users with a narrow viewing window in their browser) the less-important tab actions which the user's browser is unable to fit in. {{Identical|More}}", + "vector-search-loader": "Text to display below search input while the search suggestion module is loading" } diff --git a/jsdoc.json b/jsdoc.json index c65ce84..0aa173c 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -17,9 +17,12 @@ }, "wmf": { "linkMap": { + "\"addEventListener\"": "https://developer.mozilla.org/docs/Web/API/EventTarget/addEventListener", "Document": "https://developer.mozilla.org/docs/Web/API/Document", + "Event": "https://developer.mozilla.org/docs/Web/API/Event", "HTMLElement": "https://developer.mozilla.org/docs/Web/API/HTMLElement", "HTMLInputElement": "https://developer.mozilla.org/docs/Web/API/HTMLInputElement", + "\"removeEventListener\"": "https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener", "Window": "https://developer.mozilla.org/docs/Web/API/Window", "CheckboxHack": "https://doc.wikimedia.org/mediawiki-core/master/js", diff --git a/resources/mediawiki.d.ts b/resources/mediawiki.d.ts index e21a55a..5048a95 100644 --- a/resources/mediawiki.d.ts +++ b/resources/mediawiki.d.ts @@ -24,7 +24,36 @@ interface MediaWiki { Api: MwApiConstructor; config: { get( configKey: string|null ): string; - } + }, + loader: { + /** + * Execute a function after one or more modules are ready. + * + * @param moduleName + */ + using( moduleName: string|null ): JQuery.Promise; + + /** + * Load a given resourceLoader module. + * + * @param moduleName + */ + load( moduleName: string|null ): () => void; + /** + * Get the loading state of the module. + * On of 'registered', 'loaded', 'loading', 'ready', 'error', or 'missing'. + * + * @param moduleName + */ + getState( moduleName: string|null ): string; + }, + /** + * Loads the specified i18n message string. + * Shortcut for `mw.message( key, parameters... ).text()`. + * + * @param messageName i18n message name + */ + msg( messageName: string|null ): string; } declare const mw: MediaWiki; diff --git a/resources/skins.vector.js/searchLoader.js b/resources/skins.vector.js/searchLoader.js new file mode 100644 index 0000000..427081b --- /dev/null +++ b/resources/skins.vector.js/searchLoader.js @@ -0,0 +1,139 @@ +/** + * Disabling this rule as it's only necessary for + * combining multiple class names and documenting the output. + * That doesn't happen in this file but the linter still throws an error. + * https://github.com/wikimedia/eslint-plugin-mediawiki/blob/master/docs/rules/class-doc.md + */ +/* eslint-disable mediawiki/class-doc */ + +/** @interface VectorResourceLoaderVirtualConfig */ +/** @interface MediaWikiPageReadyModule */ + +var /** @type {VectorResourceLoaderVirtualConfig} */ + config = require( /** @type {string} */ ( './config.json' ) ), + SEARCH_FORM_ID = 'simpleSearch', + SEARCH_INPUT_ID = 'searchInput', + SEARCH_LOADING_CLASS = 'search-form__loader', + SEARCH_MODULE_NAME = config.wgVectorUseCoreSearch ? + 'mediawiki.searchSuggest' : + 'skins.vector.search'; + +/** + * 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 ) { + + function requestSearchModule() { + mw.loader.using( moduleName ).then( 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 ) { + + var form = /** @type {HTMLElement} */ ( event.currentTarget ), + input = /** @type {HTMLInputElement} */ ( event.target ); + + if ( + !( event.currentTarget instanceof HTMLElement ) || + !( event.target instanceof HTMLInputElement ) || + !( input.id === SEARCH_INPUT_ID ) ) { + return; + } + + if ( !form.dataset.loadingMsg ) { + form.dataset.loadingMsg = mw.msg( 'vector-search-loader' ); + } + + 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" } */ + var addOrRemoveListener = ( attach ? 'addEventListener' : 'removeEventListener' ); + + [ 'input', 'focusin', 'focusout' ].forEach( function ( eventType ) { + element[ addOrRemoveListener ]( eventType, eventCallback ); + } ); + + if ( !attach ) { + element.classList.remove( SEARCH_LOADING_CLASS ); + } +} + +/** + * Initialize the loading of the search module as well as the loading indicator. + * Only initialize the loading indicator when not using the core search module. + * + * @param {Document} document + */ +function initSearchLoader( document ) { + var searchForm = document.getElementById( SEARCH_FORM_ID ), + searchInput = document.getElementById( SEARCH_INPUT_ID ); + + if ( !searchForm || !searchInput ) { + return; + } + + /** + * 1. If we're using the search module from MediaWiki Core (searchSuggest), + * load the module. + * 2. If we're using a different search module, enable the loading indicator + * before the search module loads. + **/ + if ( config.wgVectorUseCoreSearch ) { + loadSearchModule( searchInput, SEARCH_MODULE_NAME, function () {} ); + } else { + setLoadingIndicatorListeners( searchForm, true, renderSearchLoadingIndicator ); + loadSearchModule( + searchInput, + SEARCH_MODULE_NAME, + setLoadingIndicatorListeners.bind( null, + searchForm, false, renderSearchLoadingIndicator ) + ); + } +} + +module.exports = { + initSearchLoader: initSearchLoader +}; diff --git a/resources/skins.vector.js/skin.js b/resources/skins.vector.js/skin.js index 471ff1c..77b784c 100644 --- a/resources/skins.vector.js/skin.js +++ b/resources/skins.vector.js/skin.js @@ -1,12 +1,6 @@ -/** @interface VectorResourceLoaderVirtualConfig */ -/** @interface MediaWikiPageReadyModule */ - var collapsibleTabs = require( '../skins.vector.legacy.js/collapsibleTabs.js' ), vector = require( '../skins.vector.legacy.js/vector.js' ), - /** @type {VectorResourceLoaderVirtualConfig} */ - config = require( /** @type {string} */ ( './config.json' ) ), - /** @type {MediaWikiPageReadyModule} */ - pageReady = require( /** @type {string} */( 'mediawiki.page.ready' ) ), + initSearchLoader = require( './searchLoader.js' ).initSearchLoader, sidebar = require( './sidebar.js' ); /** @@ -50,11 +44,7 @@ function main( window ) { collapsibleTabs.init(); sidebar.init( window ); $( vector.init ); - pageReady.loadSearchModule( - // Decide between new Vue implementation or old. - config.wgVectorUseCoreSearch ? - 'mediawiki.searchSuggest' : 'skins.vector.search' - ); + initSearchLoader( document ); } main( window ); diff --git a/resources/skins.vector.styles/SearchBoxLoader.less b/resources/skins.vector.styles/SearchBoxLoader.less new file mode 100644 index 0000000..9c6fbe3 --- /dev/null +++ b/resources/skins.vector.styles/SearchBoxLoader.less @@ -0,0 +1,78 @@ +/** + * Loading indicator for search widget + * + * By adding a class on the parent search form + *
+ * A pseudo element is added via ':after' that mimics the appearance + * of the search suggestion and contains the text + * "Loading search suggestions" (message key: vector-search-loader). + * + * After appearing for more than a second, a progress-bar animation appears + * above the loading indicator. + * + **/ +#simpleSearch.search-form__loader:after { + // Set the i18n message. + content: attr( data-loading-msg ); + // + // Position loader below the input. + display: block; + position: absolute; + // + // IE9-11 have some issues, but not with this basic use-case. + top: 100%; + width: 100%; + // + // Ensure it doesn't extend beyond the input. + box-sizing: border-box; + // + // Align loader style with input. + border: @border-base; + border-top-width: 0; // Hide the top border so it doesn't interfere with focus state. + border-radius: 0 0 @border-radius-base @border-radius-base; + color: @color-base--disabled; + font-size: @font-size-notification; + padding: 0.4em; + .box-shadow( @boxShadowWidget ); + // + // Hide text in case it extends beyond the input. + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + // + // Add a progress-bar to the loading indicator, + // but only show it animating after 1 second of loading. + background: /*image*/ linear-gradient( 90deg, @colorProgressive 0%, @colorProgressive 100% ) + /* position / size*/ -10% 0 ~'/' 0 2px + /* repeat */ no-repeat,/*second bg, just color*/#fff; + // + // Animates the progress-bar. + animation: /* name */ search-loader-progress-bar + /* duration */ 1200ms + /* timing function */ linear + /* delay */ 1000ms + /* iteration count */ infinite + /* fill direction */ alternate; +} + +@keyframes search-loader-progress-bar { + 0% { + background-size: 0 2px; + background-position: -10% 0; + } + + 30% { + background-size: 30% 2px; + background-position: -10% 0; + } + + 70% { + background-size: 30% 2px; + background-position: 110% 0; + } + + 100% { + background-size: 0 2px; + background-position: 110% 0; + } +} diff --git a/resources/skins.vector.styles/skin.less b/resources/skins.vector.styles/skin.less index 2eec303..cef76b9 100644 --- a/resources/skins.vector.styles/skin.less +++ b/resources/skins.vector.styles/skin.less @@ -13,6 +13,7 @@ @import 'SiteNotice.less'; @import 'Menu.less'; @import 'SearchBox.less'; + @import 'SearchBoxLoader.less'; @import 'MenuTabs.less'; @import 'TabWatchstarLink.less'; @import 'MenuDropdown.less'; diff --git a/skin.json b/skin.json index 672181a..38c2cf4 100644 --- a/skin.json +++ b/skin.json @@ -105,12 +105,14 @@ }, "resources/skins.vector.js/sidebar.js", "resources/skins.vector.legacy.js/collapsibleTabs.js", - "resources/skins.vector.legacy.js/vector.js" + "resources/skins.vector.legacy.js/vector.js", + "resources/skins.vector.js/searchLoader.js" ], "dependencies": [ "mediawiki.page.ready", "mediawiki.util" - ] + ], + "messages": [ "vector-search-loader" ] }, "skins.vector.legacy.js": { "packageFiles": [