Adapt Recent Changes advanced filters for mobile usage
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / ui / MenuSelectWidget.js
1 var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
2 HighlightPopupWidget = require( './HighlightPopupWidget.js' ),
3 FilterMenuSectionOptionWidget = require( './FilterMenuSectionOptionWidget.js' ),
4 FilterMenuOptionWidget = require( './FilterMenuOptionWidget.js' ),
5 MenuSelectWidget;
6
7 /**
8 * A floating menu widget for the filter list
9 *
10 * @class mw.rcfilters.ui.MenuSelectWidget
11 * @extends OO.ui.MenuSelectWidget
12 *
13 * @constructor
14 * @param {mw.rcfilters.Controller} controller Controller
15 * @param {mw.rcfilters.dm.FiltersViewModel} model View model
16 * @param {Object} [config] Configuration object
17 * @cfg {boolean} [isMobile] a boolean flag determining whether the menu
18 * should display a header or not (the header is omitted on mobile).
19 * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
20 * @cfg {Object[]} [footers] An array of objects defining the footers for
21 * this menu, with a definition whether they appear per specific views.
22 * The expected structure is:
23 * [
24 * {
25 * name: {string} A unique name for the footer object
26 * $element: {jQuery} A jQuery object for the content of the footer
27 * views: {string[]} Optional. An array stating which views this footer is
28 * active on. Use null or omit to display this on all views.
29 * }
30 * ]
31 */
32 MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, config ) {
33 var header;
34
35 config = config || {};
36
37 this.controller = controller;
38 this.model = model;
39 this.currentView = '';
40 this.views = {};
41 this.userSelecting = false;
42
43 this.menuInitialized = false;
44 this.$overlay = config.$overlay || this.$element;
45 this.$body = $( '<div>' ).addClass( 'mw-rcfilters-ui-menuSelectWidget-body' );
46 this.footers = [];
47
48 // Parent
49 MenuSelectWidget.parent.call( this, $.extend( config, {
50 $autoCloseIgnore: this.$overlay,
51 width: config.isMobile ? undefined : 650,
52 // Our filtering is done through the model
53 filterFromInput: false
54 } ) );
55 this.setGroupElement(
56 $( '<div>' )
57 .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
58 );
59
60 if ( !config.isMobile ) {
61 // When hiding the header (i.e. mobile mode) avoid problems
62 // with clippable and the menu's fixed width.
63 this.setClippableElement( this.$body );
64 this.setClippableContainer( this.$element );
65
66 header = new FilterMenuHeaderWidget(
67 this.controller,
68 this.model,
69 {
70 $overlay: this.$overlay
71 }
72 );
73 }
74
75 this.noResults = new OO.ui.LabelWidget( {
76 label: mw.msg( 'rcfilters-filterlist-noresults' ),
77 classes: [ 'mw-rcfilters-ui-menuSelectWidget-noresults' ]
78 } );
79
80 // Events
81 this.model.connect( this, {
82 initialize: 'onModelInitialize',
83 searchChange: 'onModelSearchChange'
84 } );
85
86 // Initialization
87 this.$element
88 .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
89 .append( config.isMobile ? undefined : header.$element )
90 .append(
91 this.$body
92 .append( this.$group, this.noResults.$element )
93 );
94
95 // Append all footers; we will control their visibility
96 // based on view
97 config.footers = config.isMobile ? [] : config.footers || [];
98 config.footers.forEach( function ( footerData ) {
99 var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
100 adjustedData = {
101 // Wrap the element with our own footer wrapper
102 $element: $( '<div>' )
103 .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer' )
104 .addClass( 'mw-rcfilters-ui-menuSelectWidget-footer-' + footerData.name )
105 .append( footerData.$element ),
106 views: footerData.views
107 };
108
109 if ( !footerData.disabled ) {
110 this.footers.push( adjustedData );
111
112 if ( isSticky ) {
113 this.$element.append( adjustedData.$element );
114 } else {
115 this.$body.append( adjustedData.$element );
116 }
117 }
118 }.bind( this ) );
119
120 // Switch to the correct view
121 this.updateView();
122 };
123
124 /* Initialize */
125
126 OO.inheritClass( MenuSelectWidget, OO.ui.MenuSelectWidget );
127
128 /* Events */
129
130 /* Methods */
131 MenuSelectWidget.prototype.onModelSearchChange = function () {
132 this.updateView();
133 };
134
135 /**
136 * @inheritdoc
137 */
138 MenuSelectWidget.prototype.toggle = function ( show ) {
139 this.lazyMenuCreation();
140 MenuSelectWidget.parent.prototype.toggle.call( this, show );
141 // Always open this menu downwards. FilterTagMultiselectWidget scrolls it into view.
142 this.setVerticalPosition( 'below' );
143 };
144
145 /**
146 * lazy creation of the menu
147 */
148 MenuSelectWidget.prototype.lazyMenuCreation = function () {
149 var widget = this,
150 items = [],
151 viewGroupCount = {},
152 groups = this.model.getFilterGroups();
153
154 if ( this.menuInitialized ) {
155 return;
156 }
157
158 this.menuInitialized = true;
159
160 // Create shared popup for highlight buttons
161 this.highlightPopup = new HighlightPopupWidget( this.controller );
162 this.$overlay.append( this.highlightPopup.$element );
163
164 // Count groups per view
165 // eslint-disable-next-line no-jquery/no-each-util
166 $.each( groups, function ( groupName, groupModel ) {
167 if ( !groupModel.isHidden() ) {
168 viewGroupCount[ groupModel.getView() ] = viewGroupCount[ groupModel.getView() ] || 0;
169 viewGroupCount[ groupModel.getView() ]++;
170 }
171 } );
172
173 // eslint-disable-next-line no-jquery/no-each-util
174 $.each( groups, function ( groupName, groupModel ) {
175 var currentItems = [],
176 view = groupModel.getView();
177
178 if ( !groupModel.isHidden() ) {
179 if ( viewGroupCount[ view ] > 1 ) {
180 // Only add a section header if there is more than
181 // one group
182 currentItems.push(
183 // Group section
184 new FilterMenuSectionOptionWidget(
185 widget.controller,
186 groupModel,
187 {
188 $overlay: widget.$overlay
189 }
190 )
191 );
192 }
193
194 // Add items
195 widget.model.getGroupFilters( groupName ).forEach( function ( filterItem ) {
196 currentItems.push(
197 new FilterMenuOptionWidget(
198 widget.controller,
199 widget.model,
200 widget.model.getInvertModel(),
201 filterItem,
202 widget.highlightPopup,
203 {
204 $overlay: widget.$overlay
205 }
206 )
207 );
208 } );
209
210 // Cache the items per view, so we can switch between them
211 // without rebuilding the widgets each time
212 widget.views[ view ] = widget.views[ view ] || [];
213 widget.views[ view ] = widget.views[ view ].concat( currentItems );
214 items = items.concat( currentItems );
215 }
216 } );
217
218 this.addItems( items );
219 this.updateView();
220 };
221
222 /**
223 * Respond to model initialize event. Populate the menu from the model
224 */
225 MenuSelectWidget.prototype.onModelInitialize = function () {
226 this.menuInitialized = false;
227 // Set timeout for the menu to lazy build.
228 setTimeout( this.lazyMenuCreation.bind( this ) );
229 };
230
231 /**
232 * Update view
233 */
234 MenuSelectWidget.prototype.updateView = function () {
235 var viewName = this.model.getCurrentView();
236
237 if ( this.views[ viewName ] && this.currentView !== viewName ) {
238 this.updateFooterVisibility( viewName );
239
240 this.$element
241 .data( 'view', viewName )
242 .removeClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + this.currentView )
243 .addClass( 'mw-rcfilters-ui-menuSelectWidget-view-' + viewName );
244
245 this.currentView = viewName;
246 this.scrollToTop();
247 }
248
249 this.postProcessItems();
250 this.clip();
251 };
252
253 /**
254 * Go over the available footers and decide which should be visible
255 * for this view
256 *
257 * @param {string} [currentView] Current view
258 */
259 MenuSelectWidget.prototype.updateFooterVisibility = function ( currentView ) {
260 currentView = currentView || this.model.getCurrentView();
261
262 this.footers.forEach( function ( data ) {
263 data.$element.toggle(
264 // This footer should only be shown if it is configured
265 // for all views or for this specific view
266 !data.views || data.views.length === 0 || data.views.indexOf( currentView ) > -1
267 );
268 } );
269 };
270
271 /**
272 * Post-process items after the visibility changed. Make sure
273 * that we always have an item selected, and that the no-results
274 * widget appears if the menu is empty.
275 */
276 MenuSelectWidget.prototype.postProcessItems = function () {
277 var i,
278 itemWasSelected = false,
279 items = this.getItems();
280
281 // If we are not already selecting an item, always make sure
282 // that the top item is selected
283 if ( !this.userSelecting ) {
284 // Select the first item in the list
285 for ( i = 0; i < items.length; i++ ) {
286 if (
287 !( items[ i ] instanceof OO.ui.MenuSectionOptionWidget ) &&
288 items[ i ].isVisible()
289 ) {
290 itemWasSelected = true;
291 this.selectItem( items[ i ] );
292 break;
293 }
294 }
295
296 if ( !itemWasSelected ) {
297 this.selectItem( null );
298 }
299 }
300
301 this.noResults.toggle( !this.getItems().some( function ( item ) {
302 return item.isVisible();
303 } ) );
304 };
305
306 /**
307 * Get the option widget that matches the model given
308 *
309 * @param {mw.rcfilters.dm.ItemModel} model Item model
310 * @return {mw.rcfilters.ui.ItemMenuOptionWidget} Option widget
311 */
312 MenuSelectWidget.prototype.getItemFromModel = function ( model ) {
313 this.lazyMenuCreation();
314 return this.views[ model.getGroupModel().getView() ].filter( function ( item ) {
315 return item.getName() === model.getName();
316 } )[ 0 ];
317 };
318
319 /**
320 * @inheritdoc
321 */
322 MenuSelectWidget.prototype.onDocumentKeyDown = function ( e ) {
323 var nextItem,
324 currentItem = this.findHighlightedItem() || this.findSelectedItem();
325
326 // Call parent
327 MenuSelectWidget.parent.prototype.onDocumentKeyDown.call( this, e );
328
329 // We want to select the item on arrow movement
330 // rather than just highlight it, like the menu
331 // does by default
332 if ( !this.isDisabled() && this.isVisible() ) {
333 switch ( e.keyCode ) {
334 case OO.ui.Keys.UP:
335 case OO.ui.Keys.LEFT:
336 // Get the next item
337 nextItem = this.findRelativeSelectableItem( currentItem, -1 );
338 break;
339 case OO.ui.Keys.DOWN:
340 case OO.ui.Keys.RIGHT:
341 // Get the next item
342 nextItem = this.findRelativeSelectableItem( currentItem, 1 );
343 break;
344 }
345
346 nextItem = nextItem && nextItem.constructor.static.selectable ?
347 nextItem : null;
348
349 // Select the next item
350 this.selectItem( nextItem );
351 }
352 };
353
354 /**
355 * Scroll to the top of the menu
356 */
357 MenuSelectWidget.prototype.scrollToTop = function () {
358 this.$body.scrollTop( 0 );
359 };
360
361 /**
362 * Set whether the user is currently selecting an item.
363 * This is important when the user selects an item that is in between
364 * different views, and makes sure we do not re-select a different
365 * item (like the item on top) when this is happening.
366 *
367 * @param {boolean} isSelecting User is selecting
368 */
369 MenuSelectWidget.prototype.setUserSelecting = function ( isSelecting ) {
370 this.userSelecting = !!isSelecting;
371 };
372
373 module.exports = MenuSelectWidget;