Adapt Recent Changes advanced filters for mobile usage
authorJan Drewniak <jan.drewniak@gmail.com>
Fri, 14 Jun 2019 10:04:01 +0000 (12:04 +0200)
committerJan Drewniak <jan.drewniak@gmail.com>
Tue, 30 Jul 2019 11:22:28 +0000 (13:22 +0200)
Changes the behaviour of the rcfilter search input by essentially
turning it into a button for mobile devices. Depending on the value
of `OO.ui.isMobile()` the input is set to readonly mode and given
a shorter message and different icon.

Setting the search input to readonly prevents onscreen keyboards from
being actived, but still opens the filter menu, so that mobile users
can still add/remove filters, just without the ability to search through
them.

Styles are also modified to make the search input appear as a button by
overriding the default readonly and placeholder styles.

Bug: T225499, T223230
Change-Id: Iaa67369542e658d3571d957a204daa7a53d1e520

languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidgetMobile.less [new file with mode: 0644]
resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less
resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js
resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js
resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js
resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js

index be06491..215a7bc 100644 (file)
        "rcfilters-clear-all-filters": "Clear all filters",
        "rcfilters-show-new-changes": "View new changes since $1",
        "rcfilters-search-placeholder": "Filter changes (use menu or search for filter name)",
+       "rcfilters-search-placeholder-mobile": "Filters",
        "rcfilters-invalid-filter": "Invalid filter",
        "rcfilters-empty-filter": "No active filters. All contributions are shown.",
        "rcfilters-filterlist-title": "Filters",
index fb6585b..b0bd2d0 100644 (file)
        "rcfilters-clear-all-filters": "Title for the button that clears all filters",
        "rcfilters-show-new-changes": "Label for the button to show new changes. Parameters:\n* $1 - timestamp from which new changes are available. It indicates that clicking the refresh link will bring changes newer than (or equal to) this timestamp. It is formatted according to the user's date, time and timezone preferences",
        "rcfilters-search-placeholder": "Placeholder for the filter search input. The first \"Filter\" is a verb, and the second \"filter\" is a noun.",
+       "rcfilters-search-placeholder-mobile": "Placeholder for the filter search input for mobile devices.",
        "rcfilters-invalid-filter": "A label for an invalid filter.",
        "rcfilters-empty-filter": "Placeholder for the filter list when no filters were chosen.",
        "rcfilters-filterlist-title": "Title for the filters list.\n{{Identical|Filter}}",
index 9a7b9e8..da01122 100644 (file)
@@ -1856,6 +1856,7 @@ return [
                        'styles/mw.rcfilters.ui.RclToOrFromWidget.less',
                        'styles/mw.rcfilters.ui.RclTargetPageWidget.less',
                        'styles/mw.rcfilters.ui.WatchlistTopSectionWidget.less',
+                       'styles/mw.rcfilters.ui.FilterTagMultiselectWidgetMobile.less'
                ],
                'skinStyles' => [
                        'vector' => [
@@ -1903,6 +1904,7 @@ return [
                        'rcfilters-clear-all-filters',
                        'rcfilters-show-new-changes',
                        'rcfilters-search-placeholder',
+                       'rcfilters-search-placeholder-mobile',
                        'rcfilters-invalid-filter',
                        'rcfilters-empty-filter',
                        'rcfilters-filterlist-title',
@@ -1964,6 +1966,7 @@ return [
                        'oojs-ui.styles.icons-interactions',
                        'oojs-ui.styles.icons-layout',
                        'oojs-ui.styles.icons-media',
+                       'oojs-ui-windows.icons'
                ],
        ],
        'mediawiki.interface.helpers.styles' => [
index 87f257b..4338b0b 100644 (file)
@@ -28,7 +28,6 @@
        }
 
        &-results {
-               width: 35em;
                margin: 5em auto;
 
                &-noresult,
 .mw-rcfilters-highlight-color-c1.mw-rcfilters-highlight-color-c2.mw-rcfilters-highlight-color-c3.mw-rcfilters-highlight-color-c4.mw-rcfilters-highlight-color-c5 {
        .highlight-results( tint( mix( @highlight-c1, mix( @highlight-c2, mix( @highlight-c3, average( @highlight-c4, @highlight-c5 ), 20% ), 20% ), 20% ), 15% ) );
 }
+
+@media screen and ( min-width: @width-breakpoint-tablet ) {
+       // center conflict message
+       // e.g. Special:RecentChanges?goodfaith=maybebad&hidepageedits=1&hidenewpages=1&hidecategorization=1&hideWikibase=1&limit=50&days=0.0833&enhanced=1&urlversion=2
+       // More context in https://phabricator.wikimedia.org/T223363#5374874
+       .mw-rcfilters-ui-changesListWrapperWidget {
+               &-results {
+                       width: 35em;
+               }
+       }
+}
diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidgetMobile.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidgetMobile.less
new file mode 100644 (file)
index 0000000..d3b4a2b
--- /dev/null
@@ -0,0 +1,35 @@
+@import 'mediawiki.mixins';
+@import 'mediawiki.ui/variables';
+
+.mw-rcfilters-ui-filterTagMultiselectWidget-mobile {
+
+       // Them mobile version of the search input is meant to function
+       // as a button, so styles are modified to that effect. See T224655 for details.
+       .oo-ui-tagMultiselectWidget-input {
+               & .oo-ui-iconElement-icon {
+                       opacity: 1;
+                       cursor: pointer;
+               }
+
+               &.oo-ui-textInputWidget input[ readonly ] {
+                       background-color: @background-color-base;
+                       font-weight: bold;
+                       cursor: pointer;
+                       .mixin-placeholder( { color: @colorText; } );
+               }
+       }
+
+       .mw-rcfilters-ui-filterTagMultiselectWidget-mobile-view {
+               width: 100%;
+               margin-top: -1px;
+
+               & .oo-ui-buttonOptionWidget {
+                       width: 50%;
+
+                       & .oo-ui-buttonElement-button {
+                               width: 100%;
+                               text-align: initial;
+                       }
+               }
+       }
+}
index 949980d..4e7d02d 100644 (file)
        }
 
        &-bottom {
-               .flex-display;
-               .flex;
+               .flex-display();
+               .flex();
+               flex-wrap: wrap;
                margin-top: 1em;
        }
+
+       &-bottom-mobile {
+               .oo-ui-buttonElement {
+                       margin-bottom: 1em;
+
+                       &-button {
+                               text-align: left;
+                       }
+               }
+
+               .mw-rcfilters-ui-changesLimitAndDateButtonWidget {
+                       order: 1;
+               }
+
+               .mw-rcfilters-ui-liveUpdateButtonWidget {
+                       order: 2;
+               }
+
+               .mw-rcfilters-ui-filterWrapperWidget-showNewChanges {
+                       order: 3;
+                       font-size: 0.85em;
+
+                       & > a {
+                               white-space: normal;
+                               /* stylelint-disable-next-line */
+                               padding-top: 0 !important; //overrides .oo-ui-buttonElement-button
+                       }
+               }
+       }
 }
index a0c0d80..ce869e3 100644 (file)
@@ -42,13 +42,14 @@ ChangesLimitPopupWidget = function MwRcfiltersUiChangesLimitPopupWidget( limitMo
                .addClass( 'mw-rcfilters-ui-changesLimitPopupWidget' )
                .append(
                        this.valuePicker.$element,
-                       new OO.ui.FieldLayout(
-                               this.groupByPageCheckbox,
-                               {
-                                       align: 'inline',
-                                       label: mw.msg( 'rcfilters-group-results-by-page' )
-                               }
-                       ).$element
+                       OO.ui.isMobile() ? undefined :
+                               new OO.ui.FieldLayout(
+                                       this.groupByPageCheckbox,
+                                       {
+                                               align: 'inline',
+                                               label: mw.msg( 'rcfilters-group-results-by-page' )
+                                       }
+                               ).$element
                );
 };
 
index 3429590..a50cd0e 100644 (file)
@@ -40,6 +40,7 @@ FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( c
        this.matchingQuery = null;
        this.currentView = this.model.getCurrentView();
        this.collapsed = false;
+       this.isMobile = config.isMobile;
 
        // Parent
        FilterTagMultiselectWidget.parent.call( this, $.extend( true, {
@@ -55,6 +56,8 @@ FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( c
                        filterFromInput: false,
                        hideWhenOutOfView: false,
                        hideOnChoose: false,
+                       // Only set width and footers for desktop
+                       isMobile: this.isMobile,
                        width: 650,
                        footers: [
                                {
@@ -81,9 +84,17 @@ FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( c
                                }
                        ]
                },
+               /**
+                * In the presence of an onscreen keyboard (i.e. isMobile) the filter input should act as a button
+                * rather than a text input. Mobile screens are too small to accommodate both an
+                * onscreen keyboard and a popup-menu, so readyOnly is set to disable the keyboard.
+                * A different icon and shorter message is used for mobile as well. (See T224655 for details).
+                */
                input: {
-                       icon: 'menu',
-                       placeholder: mw.msg( 'rcfilters-search-placeholder' )
+                       icon: this.isMobile ? 'funnel' : 'menu',
+                       placeholder: this.isMobile ? mw.msg( 'rcfilters-search-placeholder-mobile' ) : mw.msg( 'rcfilters-search-placeholder' ),
+                       readOnly: !!this.isMobile,
+                       classes: [ 'oo-ui-tagMultiselectWidget-input' ]
                }
        }, config ) );
 
@@ -148,11 +159,14 @@ FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( c
        this.model.connect( this, {
                initialize: 'onModelInitialize',
                update: 'onModelUpdate',
-               searchChange: 'onModelSearchChange',
+               searchChange: this.isMobile ? function () {} : 'onModelSearchChange',
                itemUpdate: 'onModelItemUpdate',
                highlightChange: 'onModelHighlightChange'
        } );
-       this.input.connect( this, { change: 'onInputChange' } );
+
+       if ( !this.isMobile ) {
+               this.input.connect( this, { change: 'onInputChange' } );
+       }
 
        // The filter list and button should appear side by side regardless of how
        // wide the button is; the button also changes its width depending
@@ -176,46 +190,10 @@ FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( c
        }
 
        // Add a selector at the right of the input
-       this.viewsSelectWidget = new OO.ui.ButtonSelectWidget( {
-               classes: [ 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget' ],
-               items: [
-                       new OO.ui.ButtonOptionWidget( {
-                               framed: false,
-                               data: 'namespaces',
-                               icon: 'article',
-                               label: mw.msg( 'namespaces' ),
-                               title: mw.msg( 'rcfilters-view-namespaces-tooltip' )
-                       } ),
-                       new OO.ui.ButtonOptionWidget( {
-                               framed: false,
-                               data: 'tags',
-                               icon: 'tag',
-                               label: mw.msg( 'tags-title' ),
-                               title: mw.msg( 'rcfilters-view-tags-tooltip' )
-                       } )
-               ]
-       } );
+       this.viewsSelectWidget = this.createViewsSelectWidget();
 
-       // Rearrange the UI so the select widget is at the right of the input
-       this.$element.append(
-               $( '<div>' )
-                       .addClass( 'mw-rcfilters-ui-table' )
-                       .append(
-                               $( '<div>' )
-                                       .addClass( 'mw-rcfilters-ui-row' )
-                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
-                                       .append(
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
-                                                       .append( this.input.$element ),
-                                               $( '<div>' )
-                                                       .addClass( 'mw-rcfilters-ui-cell' )
-                                                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
-                                                       .append( this.viewsSelectWidget.$element )
-                                       )
-                       )
-       );
+       // change the layout of the viewsSelectWidget
+       this.restructureViewsSelectWidget();
 
        // Event
        this.viewsSelectWidget.connect( this, { choose: 'onViewsSelectWidgetChoose' } );
@@ -258,6 +236,11 @@ FilterTagMultiselectWidget = function MwRcfiltersUiFilterTagMultiselectWidget( c
        this.$element
                .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget' );
 
+       if ( this.isMobile ) {
+               this.$element
+                       .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-mobile' );
+       }
+
        this.reevaluateResetRestoreState();
 };
 
@@ -267,6 +250,78 @@ OO.inheritClass( FilterTagMultiselectWidget, OO.ui.MenuTagMultiselectWidget );
 
 /* Methods */
 
+/**
+ * Create a OOUI ButtonSelectWidget. The buttons are framed and have additional CSS
+ * classes applied on mobile.
+ * @return {OO.ui.ButtonSelectWidget}
+ */
+FilterTagMultiselectWidget.prototype.createViewsSelectWidget = function () {
+       return new OO.ui.ButtonSelectWidget( {
+               classes: this.isMobile ?
+                       [
+                               'mw-rcfilters-ui-table',
+                               'mw-rcfilters-ui-filterTagMultiselectWidget-mobile-view'
+                       ] :
+                       [
+                               'mw-rcfilters-ui-filterTagMultiselectWidget-views-select-widget'
+                       ],
+               items: [
+                       new OO.ui.ButtonOptionWidget( {
+                               framed: !!this.isMobile,
+                               data: 'namespaces',
+                               icon: 'article',
+                               label: mw.msg( 'namespaces' ),
+                               classes: this.isMobile ? [ 'mw-rcfilters-ui-cell' ] : []
+                       } ),
+                       new OO.ui.ButtonOptionWidget( {
+                               framed: !!this.isMobile,
+                               data: 'tags',
+                               icon: 'tag',
+                               label: mw.msg( 'tags-title' ),
+                               title: mw.msg( 'rcfilters-view-tags-tooltip' ),
+                               classes: this.isMobile ? [ 'mw-rcfilters-ui-cell' ] : []
+                       } )
+               ]
+       } );
+};
+
+/**
+ * Rearrange the DOM structure of the viewsSelectWiget so that on the namespace & tags buttons
+ * are at the right of the input on desktop, and below the input on mobile.
+ */
+FilterTagMultiselectWidget.prototype.restructureViewsSelectWidget = function () {
+       if ( this.isMobile ) {
+               // On mobile, append the search input and the extra buttons below the search input.
+               this.$element.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
+                               .append( this.input.$element )
+                               .append( this.viewsSelectWidget.$element )
+               );
+       } else {
+               // On desktop, rearrange the UI so the select widget is at the right of the input
+               this.$element.append(
+                       $( '<div>' )
+                               .addClass( 'mw-rcfilters-ui-table' )
+                               .append(
+                                       $( '<div>' )
+                                               .addClass( 'mw-rcfilters-ui-row' )
+                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' )
+                                               .append(
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' )
+                                                               .append( this.input.$element ),
+                                                       $( '<div>' )
+                                                               .addClass( 'mw-rcfilters-ui-cell' )
+                                                               .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-select' )
+                                                               .append( this.viewsSelectWidget.$element )
+                                               )
+                               )
+               );
+       }
+};
+
 /**
  * Respond to view select widget choose event
  *
@@ -333,7 +388,9 @@ FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
        FilterTagMultiselectWidget.parent.prototype.onMenuToggle.call( this );
 
        if ( isVisible ) {
-               this.focus();
+               if ( !this.isMobile ) {
+                       this.focus();
+               }
 
                mw.hook( 'RcFilters.popup.open' ).fire();
 
@@ -360,21 +417,33 @@ FilterTagMultiselectWidget.prototype.onMenuToggle = function ( isVisible ) {
                this.blur();
        }
 
-       this.input.setIcon( isVisible ? 'search' : 'menu' );
+       if ( this.isMobile ) {
+               this.input.setIcon( isVisible ? 'close' : 'funnel' );
+       } else {
+               this.input.setIcon( isVisible ? 'search' : 'menu' );
+       }
 };
 
 /**
  * @inheritdoc
  */
 FilterTagMultiselectWidget.prototype.onInputFocus = function () {
-       // Parent
-       FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
+       var scrollToElement = this.isMobile ? this.input.$input : this.$element;
+
+       // treat the input as a menu toggle rather than a text field on mobile
+       if ( this.isMobile ) {
+               this.input.$input.trigger( 'blur' );
+               this.getMenu().toggle();
+       } else {
+               // Parent
+               FilterTagMultiselectWidget.parent.prototype.onInputFocus.call( this );
+       }
 
        // Only scroll to top of the viewport if:
        // - The widget is more than 20px from the top
        // - The widget is not above the top of the viewport (do not scroll downwards)
        //   (This isn't represented because >20 is, anyways and always, bigger than 0)
-       this.scrollToTop( this.$element, 0, { min: 20, max: Infinity } );
+       this.scrollToTop( scrollToElement, 0, { min: 20, max: Infinity } );
 };
 
 /**
@@ -525,7 +594,10 @@ FilterTagMultiselectWidget.prototype.onMenuChoose = function ( item ) {
        // Select the tag if it exists, or reset selection otherwise
        this.selectTag( this.findItemFromData( item.model.getName() ) );
 
-       this.focus();
+       if ( !this.isMobile ) {
+               this.focus();
+       }
+
 };
 
 /**
index a0f098c..5893a6c 100644 (file)
@@ -47,7 +47,8 @@ FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
                {
                        $overlay: this.$overlay,
                        collapsed: config.collapsed,
-                       $wrapper: this.$wrapper
+                       $wrapper: this.$wrapper,
+                       isMobile: OO.ui.isMobile()
                }
        );
 
@@ -82,7 +83,11 @@ FilterWrapperWidget = function MwRcfiltersUiFilterWrapperWidget(
                .addClass( 'mw-rcfilters-ui-filterWrapperWidget-top' );
 
        $bottom = $( '<div>' )
-               .addClass( 'mw-rcfilters-ui-filterWrapperWidget-bottom' )
+               .addClass( OO.ui.isMobile() ?
+                       'mw-rcfilters-ui-filterWrapperWidget-bottom ' +
+                       'mw-rcfilters-ui-filterWrapperWidget-bottom-mobile' :
+                       'mw-rcfilters-ui-filterWrapperWidget-bottom'
+               )
                .append(
                        this.showNewChangesLink.$element,
                        this.numChangesAndDateWidget.$element
index 1e75020..465d5b9 100644 (file)
@@ -14,6 +14,8 @@ var FilterMenuHeaderWidget = require( './FilterMenuHeaderWidget.js' ),
  * @param {mw.rcfilters.Controller} controller Controller
  * @param {mw.rcfilters.dm.FiltersViewModel} model View model
  * @param {Object} [config] Configuration object
+ * @cfg {boolean} [isMobile] a boolean flag determining whether the menu
+ * should display a header or not (the header is omitted on mobile).
  * @cfg {jQuery} [$overlay] A jQuery object serving as overlay for popups
  * @cfg {Object[]} [footers] An array of objects defining the footers for
  *  this menu, with a definition whether they appear per specific views.
@@ -46,7 +48,7 @@ MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, co
        // Parent
        MenuSelectWidget.parent.call( this, $.extend( config, {
                $autoCloseIgnore: this.$overlay,
-               width: 650,
+               width: config.isMobile ? undefined : 650,
                // Our filtering is done through the model
                filterFromInput: false
        } ) );
@@ -54,16 +56,21 @@ MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, co
                $( '<div>' )
                        .addClass( 'mw-rcfilters-ui-menuSelectWidget-group' )
        );
-       this.setClippableElement( this.$body );
-       this.setClippableContainer( this.$element );
-
-       header = new FilterMenuHeaderWidget(
-               this.controller,
-               this.model,
-               {
-                       $overlay: this.$overlay
-               }
-       );
+
+       if ( !config.isMobile ) {
+               // When hiding the header (i.e. mobile mode) avoid problems
+               // with clippable and the menu's fixed width.
+               this.setClippableElement( this.$body );
+               this.setClippableContainer( this.$element );
+
+               header = new FilterMenuHeaderWidget(
+                       this.controller,
+                       this.model,
+                       {
+                               $overlay: this.$overlay
+                       }
+               );
+       }
 
        this.noResults = new OO.ui.LabelWidget( {
                label: mw.msg( 'rcfilters-filterlist-noresults' ),
@@ -79,7 +86,7 @@ MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, co
        // Initialization
        this.$element
                .addClass( 'mw-rcfilters-ui-menuSelectWidget' )
-               .append( header.$element )
+               .append( config.isMobile ? undefined : header.$element )
                .append(
                        this.$body
                                .append( this.$group, this.noResults.$element )
@@ -87,7 +94,7 @@ MenuSelectWidget = function MwRcfiltersUiMenuSelectWidget( controller, model, co
 
        // Append all footers; we will control their visibility
        // based on view
-       config.footers = config.footers || [];
+       config.footers = config.isMobile ? [] : config.footers || [];
        config.footers.forEach( function ( footerData ) {
                var isSticky = footerData.sticky === undefined ? true : !!footerData.sticky,
                        adjustedData = {