wikimedia/mediawiki-extensions-UniversalLanguageSelector

View on GitHub
lib/jquery.uls/src/jquery.uls.lcd.js

Summary

Maintainability
D
1 day
Test Coverage
/**
 * Universal Language Selector
 * Language category display component - Used for showing the search results,
 * grouped by regions, scripts
 *
 * Copyright (C) 2012 Alolita Sharma, Amir Aharoni, Arun Ganesh, Brandon Harris,
 * Niklas Laxström, Pau Giner, Santhosh Thottingal, Siebrand Mazeland and other
 * contributors. See CREDITS for a list.
 *
 * UniversalLanguageSelector is dual licensed GPLv2 or later and MIT. You don't
 * have to do anything special to choose one license or the other and you don't
 * have to notify anyone which license you are using. You are free to use
 * UniversalLanguageSelector in commercial projects as long as the copyright
 * header is left intact. See files GPL-LICENSE and MIT-LICENSE for details.
 *
 * @file
 * @license GNU General Public Licence 2.0 or later
 * @license MIT License
 */

( function ( $ ) {
    'use strict';

    var noResultsTemplate = '<div class="uls-no-results-view"> \
        <h2 data-i18n="uls-no-results-found" class="uls-no-results-found-title">No results found</h2> \
        <div class="uls-no-results-suggestions"></div> \
        <div class="uls-no-found-more"> \
        <div data-i18n="uls-search-help">You can search by language name, script name, ISO code of language or you can browse by region.</div> \
        </div></div>';

    /**
     * Language category display
     *
     * @param {Element} element The container element to which the languages to be displayed
     * @param {Object} [options] Configuration object
     * @param {Object} [options.languages] Selectable languages. Keyed by language code, values are autonyms.
     * @param {string[]} [options.showRegions] Array of region codes to show. Default is
     *  [ 'WW', 'AM', 'EU', 'ME', 'AF', 'AS', 'PA' ]
     * @param {number} [options.itemsPerColumn] Number of languages per column.
     * @param {number} [options.columns] Number of columns for languages. Default is 4.
     * @param {Function} [options.languageDecorator] Callback function to be called when a language
     *  link is prepared - for custom decoration.
     * @param {Function|string[]} [options.quickList] The languages to display as suggestions for quick
     *  selection.
     * @param {Function} [options.clickhandler] Callback when language is selected.
     * @param {jQuery|Function} [options.noResultsTemplate]
     */
    function LanguageCategoryDisplay( element, options ) {
        this.$element = $( element );
        this.options = $.extend( {}, $.fn.lcd.defaults, options );
        // Ensure the internal region 'all' is always present
        if ( this.options.showRegions.indexOf( 'all' ) === -1 ) {
            this.options.showRegions.push( 'all' );
        }

        this.$element.addClass( 'uls-lcd' );
        this.regionLanguages = {};
        this.renderTimeout = null;
        this.$cachedQuicklist = null;
        this.groupByRegionOverride = null;

        // The index of the language option that is currently visited using arrow key navigation
        // Can take values in the [0, language options list item length - 1] range for top to bottom
        // navigation, or in the [-1, -language options list item length + 1] range for bottom to
        // top navigation.
        this.navigationIndex = null;

        this.render();
        this.listen();
    }

    // Adapted from https://stackoverflow.com/a/41754707/903324
    function isLanguageFullyVisible( $el, $holder ) {
        var elementRect = $el.get( 0 ).getBoundingClientRect();
        var holderRect = $holder.get( 0 ).getBoundingClientRect();

        return elementRect.top <= holderRect.top ?
            holderRect.top <= elementRect.top :
            holderRect.bottom <= elementRect.height;
    }

    LanguageCategoryDisplay.prototype = {
        constructor: LanguageCategoryDisplay,

        /**
         * Returns a jQuery object containing a collection of all the visible
         * language option <li> elements
         *
         * @return {jQuery}
         */
        getLanguageOptionListItems: function () {
            return this.$element.find( '.uls-lcd-region-section:not(.hide)' ).find( 'li[data-code]' );
        },

        /**
         * Increases the keyboard navigation index by one and applies a specific
         * class to the n-th language option <li> element (where n = navigation index)
         * Currently used as event handler for the arrow down 'keydown' event, inside
         * LanguageFilter.
         */
        navigateDown: function () {
            var maxIndex = this.getLanguageOptionListItems().length - 1;
            // We support navigation starting both from the top and the bottom of the language list.
            // The navigation should stop when the last language option is already highlighted (for
            // top to bottom navigation). For top to bottom navigation, that happens when navigation
            // index is equal to language options list item length - 1. For bottom to top
            // navigation, that happens when navigation index is equal to -1.
            if ( this.navigationIndex === maxIndex || this.navigationIndex === -1 ) {
                return;
            }

            if ( this.navigationIndex === null ) {
                this.navigationIndex = 0;
            } else {
                // Auto-correct the index.
                // It has been noticed that the navigationIndex goes beyond
                // the max index sometimes. See: phab:T328956#8854835
                if ( this.navigationIndex > maxIndex ) {
                    this.navigationIndex = maxIndex;
                } else {
                    this.navigationIndex++;
                }
            }

            this.highlightLanguageOption();
        },

        /**
         * Decreases the keyboard navigation index by one and applies a specific
         * class to the n-th language option <li> element (where n = navigation index)
         * Currently used as event handler for the arrow down 'keydown' event, inside
         * LanguageFilter.
         */
        navigateUp: function () {
            var maxIndex = this.getLanguageOptionListItems().length - 1;
            // We support navigation starting both from the top and the bottom of the language list.
            // The navigation should stop when the first language option is already highlighted (for
            // bottom to top navigation). For top to bottom navigation, that happens when navigation
            // index is equal to 0. For bottom to top navigation, that happens when navigation index
            // is equal to -languageOptionListItemsLength + 1.
            if ( this.navigationIndex === 0 || this.navigationIndex === -maxIndex ) {
                return;
            }

            if ( this.navigationIndex === null ) {
                return;
            }

            this.navigationIndex--;
            this.highlightLanguageOption();
        },

        /**
         * Adds a specific class ("uls-language-option--highlighted") only to the n-th
         * language option <li> element (where n = navigation index)
         */
        highlightLanguageOption: function () {
            var $listItems = this.getLanguageOptionListItems();
            // There are no language items. Nothing to highlight.
            if ( !$listItems.length ) {
                return;
            }
            $listItems.removeClass( 'uls-language-option--highlighted' );

            var $selectedItem = $listItems.eq( this.navigationIndex );
            if ( $selectedItem.length === 0 ) {
                throw new Error(
                    'Could not identify selected item with index: ' + this.navigationIndex + '; ' +
                    'Total items in ULS: ' + $listItems.length
                );
            }

            $selectedItem.addClass( 'uls-language-option--highlighted' );

            // If the selected item is not visible, then scroll the container to display it
            if ( !isLanguageFullyVisible( $selectedItem, this.$element ) ) {
                $selectedItem.get( 0 ).scrollIntoView( false );
            }
        },

        getHighlightedLanguageCode: function () {
            if ( this.navigationIndex ) {
                var $selectedItem = this.getLanguageOptionListItems().eq( this.navigationIndex );
                return $selectedItem.data( 'code' );
            }

            return null;
        },

        /**
         * Resets the navigation index to null.
         * Currently used inside LanguageFilter search method, to reset the keyboard navigation
         */
        resetNavigationIndex: function () {
            this.navigationIndex = null;
        },

        /**
         * Adds language to the language list.
         *
         * @param {string} langCode
         * @param {string} [regionCode]
         * @return {boolean} Whether the language was known and accepted
         */
        append: function ( langCode, regionCode ) {
            var i, regions;

            if ( !$.uls.data.languages[ langCode ] ) {
                // Language is unknown or not in the list of languages for this context.
                return false;
            }

            if ( !this.isGroupingByRegionEnabled() ) {
                regions = [ 'all' ];

                // Make sure we do not get duplicates
                if ( this.regionLanguages.all.indexOf( langCode ) > -1 ) {
                    return true;
                }
            } else {
                if ( regionCode ) {
                    regions = [ regionCode ];
                } else {
                    regions = $.uls.data.getRegions( langCode );
                }
            }

            for ( i = 0; i < regions.length; i++ ) {
                if ( this.regionLanguages[ regions[ i ] ] ) {
                    this.regionLanguages[ regions[ i ] ].push( langCode );
                }
            }

            // Work around the bad interface, delay rendering until we have got
            // all the languages to speed up performance.
            clearTimeout( this.renderTimeout );
            this.renderTimeout = setTimeout( function () {
                this.renderRegions();
            }.bind( this ), 50 );

            return true;
        },

        /**
         * Whether we should render languages grouped to geographic regions.
         *
         * @return {boolean}
         */
        isGroupingByRegionEnabled: function () {
            if ( this.groupByRegionOverride !== null ) {
                return this.groupByRegionOverride;
            } else if ( this.options.groupByRegion !== 'auto' ) {
                return this.options.groupByRegion;
            } else {
                return this.options.columns > 1;
            }
        },

        /**
         * Override the default region grouping setting.
         * This is to allow LanguageFilter to disable grouping when displaying search results.
         *
         * @param {boolean|null} val True to force grouping, false to disable, null
         * to undo override.
         */
        setGroupByRegionOverride: function ( val ) {
            this.groupByRegionOverride = val;
        },

        render: function () {
            var $section,
                $quicklist = this.buildQuicklist(),
                regions = [],
                regionNames = {
                    // These are fallback text when i18n library not present
                    all: 'All languages', // Used if there is quicklist and no region grouping
                    WW: 'Worldwide',
                    SP: 'Special',
                    AM: 'America',
                    EU: 'Europe',
                    ME: 'Middle East',
                    AS: 'Asia',
                    AF: 'Africa',
                    PA: 'Pacific'
                };

            if ( $quicklist.length ) {
                regions.push( $quicklist );
            } else {
                // We use CSS to hide the header for 'all' when quicklist is NOT present
                this.$element.addClass( 'uls-lcd--no-quicklist' );
            }

            this.options.showRegions.forEach( function ( regionCode ) {
                this.regionLanguages[ regionCode ] = [];

                $section = $( '<div>' )
                    .addClass( 'uls-lcd-region-section hide' )
                    .attr( 'data-region', regionCode );

                $( '<h3>' )
                    .attr( 'data-i18n', 'uls-region-' + regionCode )
                    .addClass( 'uls-lcd-region-title' )
                    .text( regionNames[ regionCode ] )
                    .appendTo( $section );

                regions.push( $section );
            }.bind( this ) );

            this.$element.append( regions );

            this.i18n();
        },

        /**
         * Renders a region and displays it if it has content.
         */
        renderRegions: function () {
            var languages,
                lcd = this;

            this.$element.removeClass( 'uls-no-results' );
            this.$element.children( '.uls-lcd-region-section' ).each( function () {
                var $region = $( this ),
                    regionCode = $region.data( 'region' );

                if ( $region.is( '.uls-lcd-quicklist' ) ) {
                    return;
                }

                $region.children( '.uls-language-block' ).remove();

                languages = lcd.regionLanguages[ regionCode ];
                if ( !languages || languages.length === 0 ) {
                    $region.addClass( 'hide' );
                    return;
                }

                lcd.renderRegion(
                    $region,
                    languages,
                    lcd.options.itemsPerColumn,
                    lcd.options.columns
                );
                $region.removeClass( 'hide' );

                lcd.regionLanguages[ regionCode ] = [];
            } );

        },

        /**
         * Adds given languages sorted into rows and columns into given element.
         *
         * @param {jQuery} $region Element to add language list.
         * @param {Array} languages List of language codes.
         * @param {number} itemsPerColumn How many languages fit in a column.
         * @param {number} columnsPerRow How many columns fit in a row.
         */
        renderRegion: function ( $region, languages, itemsPerColumn, columnsPerRow ) {
            var columnsClasses, i, lastItem, currentScript, nextScript, force,
                languagesCount = languages.length,
                items = [],
                columns = [],
                rows = [];

            languages = $.uls.data.sortByScriptGroup(
                languages.sort( $.uls.data.sortByAutonym )
            );

            if ( columnsPerRow === 1 ) {
                columnsClasses = 'twelve columns';
            } else if ( columnsPerRow === 2 ) {
                columnsClasses = 'six columns';
            } else {
                columnsClasses = 'three columns';
            }

            if ( this.options.columns === 1 ) {
                // For one-column narrow ULS, just render all the languages
                // in one simple list without separators or script groups
                for ( i = 0; i < languagesCount; i++ ) {
                    items.push( this.renderItem( languages[ i ] ) );
                }

                columns.push( $( '<ul>' ).addClass( columnsClasses ).append( items ) );
                rows.push( $( '<div>' ).addClass( 'row uls-language-block' ).append( columns ) );
            } else {
                // For medium and wide ULS, clever column placement
                for ( i = 0; i < languagesCount; i++ ) {
                    force = false;
                    nextScript = $.uls.data.getScriptGroupOfLanguage( languages[ i + 1 ] );

                    lastItem = languagesCount - i === 1;
                    // Force column break if script changes and column has more than one
                    // row already, but only if grouping by region
                    if ( i === 0 || !this.isGroupingByRegionEnabled() ) {
                        currentScript = $.uls.data.getScriptGroupOfLanguage( languages[ i ] );
                    } else if ( currentScript !== nextScript && items.length > 1 ) {
                        force = true;
                    }
                    currentScript = nextScript;

                    items.push( this.renderItem( languages[ i ] ) );

                    if ( items.length >= itemsPerColumn || lastItem || force ) {
                        columns.push( $( '<ul>' ).addClass( columnsClasses ).append( items ) );
                        items = [];
                        if ( columns.length >= columnsPerRow || lastItem ) {
                            rows.push( $( '<div>' ).addClass( 'row uls-language-block' ).append( columns ) );
                            columns = [];
                        }
                    }
                }
            }

            $region.append( rows );
        },

        /**
         * Creates dom node representing one item in language list.
         *
         * @param {string} code Language code
         * @return {Element}
         */
        renderItem: function ( code ) {
            var a, name, autonym, li;

            name = this.options.languages[ code ];
            autonym = $.uls.data.getAutonym( code ) || name || code;

            // Not using jQuery as this is performance hotspot
            li = document.createElement( 'li' );
            li.title = name;
            li.setAttribute( 'data-code', code );

            a = document.createElement( 'a' );
            a.appendChild( document.createTextNode( autonym ) );
            a.className = 'autonym';
            a.lang = code;
            a.dir = $.uls.data.getDir( code );

            li.appendChild( a );
            if ( this.options.languageDecorator ) {
                this.options.languageDecorator( $( a ), code );
            }
            return li;
        },

        i18n: function () {
            this.$element.find( '[data-i18n]' ).i18n();
        },

        /**
         * Adds quicklist as a region.
         */
        quicklist: function () {
            this.$element.find( '.uls-lcd-quicklist' ).removeClass( 'hide' );
        },

        buildQuicklist: function () {
            var quickList, $quickListSection, $quickListSectionTitle;

            if ( this.$cachedQuicklist !== null ) {
                return this.$cachedQuicklist;
            }

            if ( typeof this.options.quickList === 'function' ) {
                this.options.quickList = this.options.quickList();
            }

            if ( !this.options.quickList.length ) {
                this.$cachedQuicklist = $( [] );
                return this.$cachedQuicklist;
            }

            // Pick only the first elements, because we don't have room for more
            quickList = this.options.quickList;
            quickList = quickList.slice( 0, 16 );
            quickList.sort( $.uls.data.sortByAutonym );

            $quickListSection = $( '<div>' )
                .addClass( 'uls-lcd-region-section uls-lcd-quicklist' );

            $quickListSectionTitle = $( '<h3>' )
                .attr( 'data-i18n', 'uls-common-languages' )
                .addClass( 'uls-lcd-region-title' )
                .text( 'Suggested languages' ); // This is placeholder text if jquery.i18n not present
            $quickListSection.append( $quickListSectionTitle );

            this.renderRegion(
                $quickListSection,
                quickList,
                this.options.itemsPerColumn,
                this.options.columns
            );

            $quickListSectionTitle.i18n();

            this.$cachedQuicklist = $quickListSection;
            return this.$cachedQuicklist;
        },

        show: function () {
            if ( !this.regionDivs ) {
                this.render();
            }
        },

        /**
         * Called when a fresh search is started
         */
        empty: function () {
            this.$element.addClass( 'uls-lcd--no-quicklist' );
            this.$element.find( '.uls-lcd-quicklist' ).addClass( 'hide' );
        },

        focus: function () {
            this.$element.trigger( 'focus' );
        },

        /**
         * No-results event handler
         *
         * @param {Event} event
         * @param {Object} data Information about the failed search query
         */
        noResults: function ( event, data ) {
            var $noResults;

            this.$element.addClass( 'uls-no-results' );

            this.$element.find( '.uls-no-results-view' ).remove();

            if ( typeof this.options.noResultsTemplate === 'function' ) {
                $noResults =
                    this.options.noResultsTemplate.call( this, data.query );
            } else if ( this.options.noResultsTemplate instanceof jQuery ) {
                $noResults = this.options.noResultsTemplate;
            } else {
                throw new Error( 'noResultsTemplate option must be ' +
                    'either jQuery or function returning jQuery' );
            }

            this.$element.append( $noResults.addClass( 'uls-no-results-view' ).i18n() );
        },

        listen: function () {
            var lcd = this;

            this.$element.on( 'mouseenter', 'li[data-code]', function () {
                var $listItems = lcd.getLanguageOptionListItems();
                // Remove the previous option, and then highlight the current one.
                $listItems.removeClass( 'uls-language-option--highlighted' );
                var $self = $( this );
                $self.addClass( 'uls-language-option--highlighted' );
                lcd.navigationIndex = $listItems.index( $self );
            } ).on( 'mouseleave', 'li[data-code]', function () {
                $( this ).removeClass( 'uls-language-option--highlighted' );
                lcd.navigationIndex = null;
            } );

            if ( this.options.clickhandler ) {
                this.$element.on( 'click', '.row li', function ( event ) {
                    lcd.options.clickhandler.call( this, $( this ).data( 'code' ), event );
                } );
            }
        }
    };

    $.fn.lcd = function ( option ) {
        return this.each( function () {
            var $this = $( this ),
                data = $this.data( 'lcd' ),
                options = typeof option === 'object' && option;

            if ( !data ) {
                $this.data( 'lcd', ( data = new LanguageCategoryDisplay( this, options ) ) );
            }

            if ( typeof option === 'string' ) {
                data[ option ]();
            }
        } );
    };

    $.fn.lcd.defaults = {
        // List of languages to show
        languages: [],
        // List of regions to show
        showRegions: [ 'WW', 'AM', 'EU', 'ME', 'AF', 'AS', 'PA' ],
        // Whether to group by region, defaults to true when columns > 1
        groupByRegion: 'auto',
        // How many items per column until new "row" starts
        itemsPerColumn: 8,
        // Number of columns, only 1, 2 and 4 are supported
        columns: 4,
        // Callback function for language item styling
        languageDecorator: undefined,
        // Likely candidates
        quickList: [],
        // Callback function for language selection
        clickhandler: undefined,
        // Callback function when no search results.
        // If overloaded, it can accept the search string as an argument.
        noResultsTemplate: function () {
            var $suggestionsContainer, $suggestions,
                $noResultsTemplate = $( noResultsTemplate );

            $suggestions = this.buildQuicklist().clone();
            $suggestions.removeClass( 'hide' )
                .find( 'h3' )
                .data( 'i18n', 'uls-no-results-suggestion-title' )
                .text( 'You may be interested in:' )
                .i18n();
            $suggestionsContainer = $noResultsTemplate.find( '.uls-no-results-suggestions' );
            $suggestionsContainer.append( $suggestions );
            return $noResultsTemplate;
        }
    };

}( jQuery ) );