2 * JavaScript for Special:Preferences: Tab navigation.
5 const nav = require( './nav.js' );
7 const $tabNavigationHint = nav.insertHints( mw.msg( 'prefs-tabs-navigation-hint' ) );
9 const tabs = OO.ui.infuse( $( '.mw-prefs-tabs' ) );
12 // https://bugs.chromium.org/p/chromium/issues/detail?id=1252507
14 // Infusing the tabs above involves detaching all the tabs' content from the DOM momentarily,
15 // which causes the :target selector (used in mediawiki.special.preferences.styles.ooui.less)
16 // not to match anything inside the tabs in Chrome. Twiddling location.href makes it work.
17 // Only do it when a fragment is present, otherwise the page would be reloaded.
18 if ( location.href.indexOf( '#' ) !== -1 ) {
19 // eslint-disable-next-line no-self-assign
20 location.href = location.href;
23 tabs.$element.addClass( 'mw-prefs-tabs-infused' );
25 function enhancePanel( panel ) {
26 if ( !panel.$element.data( 'mw-section-infused' ) ) {
27 panel.$element.removeClass( 'mw-htmlform-autoinfuse-lazy' );
28 mw.hook( 'htmlform.enhance' ).fire( panel.$element );
29 panel.$element.data( 'mw-section-infused', true );
33 function onTabPanelSet( panel ) {
34 if ( nav.switchingNoHash ) {
37 // Handle hash manually to prevent jumping,
38 // therefore save and restore scrollTop to prevent jumping.
39 const scrollTop = $( window ).scrollTop();
40 // Changing the hash apparently causes keyboard focus to be lost?
41 // Save and restore it. This makes no sense though.
42 const active = document.activeElement;
43 location.hash = '#' + panel.getName();
47 $( window ).scrollTop( scrollTop );
50 tabs.on( 'set', onTabPanelSet );
52 // Hash navigation callback
53 const setSection = function ( sectionName, fieldset ) {
54 tabs.setTabPanel( sectionName );
55 enhancePanel( tabs.getCurrentTabPanel() );
56 // Scroll to a fieldset if provided.
58 fieldset.scrollIntoView();
63 const onSubmit = function () {
64 const value = tabs.getCurrentTabPanelName();
65 mw.storage.session.set( 'mwpreferences-prevTab', value );
68 nav.onLoad( setSection, 'mw-prefsection-personal' );
70 nav.restorePrevSection( setSection, onSubmit );
74 function buildIndex() {
76 const $fields = tabs.contentPanel.$element.find( '[class^=mw-htmlform-field-]:not( .mw-prefs-search-noindex )' );
77 const $descFields = $fields.filter(
78 '.oo-ui-fieldsetLayout-group > .oo-ui-widget > .mw-htmlform-field-HTMLInfoField'
80 $fields.not( $descFields ).each( function () {
81 let $field = $( this );
82 const $wrapper = $field.parents( '.mw-prefs-fieldset-wrapper' );
83 const $tabPanel = $field.closest( '.oo-ui-tabPanelLayout' );
84 const $labels = $field.find(
85 '.oo-ui-labelElement-label, .oo-ui-textInputWidget .oo-ui-inputWidget-input, p'
87 $wrapper.find( '> .oo-ui-fieldsetLayout > .oo-ui-fieldsetLayout-header .oo-ui-labelElement-label' )
89 $field = $field.add( $tabPanel.find( $descFields ) );
91 function addToIndex( $label, $highlight ) {
92 const text = $label.val() || $label[ 0 ].textContent.toLowerCase().trim().replace( /\s+/, ' ' );
94 index[ text ] = index[ text ] || [];
96 $highlight: $highlight || $label,
104 $labels.each( function () {
105 addToIndex( $( this ) );
107 // Check if there we are in an infusable dropdown and collect other options
108 const $dropdown = $( this ).closest( '.oo-ui-dropdownInputWidget[data-ooui],.mw-widget-selectWithInputWidget[data-ooui]' );
109 if ( $dropdown.length ) {
110 const dropdown = OO.ui.infuse( $dropdown[ 0 ] );
111 const dropdownWidget = ( dropdown.dropdowninput || dropdown ).dropdownWidget;
112 if ( dropdownWidget ) {
113 dropdownWidget.getMenu().getItems().forEach( ( option ) => {
114 // Highlight the dropdown handle and the matched label, for when the dropdown is opened
115 addToIndex( option.$label, dropdownWidget.$handle );
116 addToIndex( option.$label, option.$label );
122 mw.hook( 'prefs.search.buildIndex' ).fire( index );
123 texts = Object.keys( index );
126 function infuseAllPanels() {
127 tabs.stackLayout.items.forEach( ( tabPanel ) => {
128 const wasVisible = tabPanel.isVisible();
129 // Force panel to be visible while infusing
130 tabPanel.toggle( true );
132 enhancePanel( tabPanel );
134 // Restore visibility
135 tabPanel.toggle( wasVisible );
139 const searchWrapper = OO.ui.infuse( $( '.mw-prefs-search' ) );
140 const search = searchWrapper.fieldWidget;
141 search.$input.on( 'focus', () => {
143 // Lazy-build index on first focus
144 // Infuse all widgets as we may end up showing a large subset of them
149 const $noResults = $( '<div>' ).addClass( 'mw-prefs-noresults' ).text( mw.msg( 'searchprefs-noresults' ) );
150 search.on( 'change', ( val ) => {
152 // In case 'focus' hasn't fired yet
156 const isSearching = !!val;
157 tabs.$element.toggleClass( 'mw-prefs-tabs-searching', isSearching );
158 tabs.tabSelectWidget.toggle( !isSearching );
159 tabs.contentPanel.setContinuous( isSearching );
161 $( '.mw-prefs-search-matched' ).removeClass( 'mw-prefs-search-matched' );
162 $( '.mw-prefs-search-highlight' ).removeClass( 'mw-prefs-search-highlight' );
163 let countResults = 0;
165 val = val.toLowerCase();
166 texts.forEach( ( text ) => {
167 // TODO: Could use Intl.Collator.prototype.compare like OO.ui.mixin.LabelElement.static.highlightQuery
168 // but might be too slow.
169 if ( text.indexOf( val ) !== -1 ) {
170 index[ text ].forEach( ( item ) => {
171 // eslint-disable-next-line no-jquery/no-class-state
172 if ( !item.$field.hasClass( 'mw-prefs-search-matched' ) ) {
173 // Count each matched preference as one result, not the number of matches in the text
176 item.$highlight.addClass( 'mw-prefs-search-highlight' );
177 item.$field.addClass( 'mw-prefs-search-matched' );
178 item.$wrapper.addClass( 'mw-prefs-search-matched' );
179 item.$tabPanel.addClass( 'mw-prefs-search-matched' );
185 // We hide the tabs when searching, so hide this tip about them as well
186 $tabNavigationHint.toggle( !isSearching );
187 // Update invisible label to give screenreader users live feedback while they're typing
188 if ( !isSearching ) {
189 searchWrapper.setLabel( mw.msg( 'searchprefs' ) );
190 } else if ( countResults === 0 ) {
191 searchWrapper.setLabel( mw.msg( 'searchprefs-noresults' ) );
193 searchWrapper.setLabel( mw.msg( 'searchprefs-results', countResults ) );
196 // Update visible label
197 if ( isSearching && countResults === 0 ) {
198 tabs.$element.append( $noResults );
203 // Make Enter jump to the results, if there are any
204 if ( isSearching && countResults !== 0 ) {
205 search.on( 'enter', () => {
206 tabs.focusFirstFocusable();
209 search.off( 'enter' );
214 // Handle the initial value in case the user started typing before this JS code loaded,
215 // or the browser restored the value for a closed tab
216 if ( search.getValue() ) {
217 search.emit( 'change', search.getValue() );