From b9886278352969b3008e49044c4e5824ad5070f3 Mon Sep 17 00:00:00 2001 From: Jan Drewniak Date: Fri, 14 Jun 2019 12:04:01 +0200 Subject: [PATCH] Adapt Recent Changes advanced filters for mobile usage 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 | 1 + languages/i18n/qqq.json | 1 + resources/Resources.php | 3 + ...rcfilters.ui.ChangesListWrapperWidget.less | 12 +- ...s.ui.FilterTagMultiselectWidgetMobile.less | 35 ++++ .../mw.rcfilters.ui.FilterWrapperWidget.less | 34 +++- .../ui/ChangesLimitPopupWidget.js | 15 +- .../ui/FilterTagMultiselectWidget.js | 170 +++++++++++++----- .../ui/FilterWrapperWidget.js | 9 +- .../ui/MenuSelectWidget.js | 33 ++-- 10 files changed, 239 insertions(+), 74 deletions(-) create mode 100644 resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidgetMobile.less diff --git a/languages/i18n/en.json b/languages/i18n/en.json index be064917f9..215a7bc980 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1483,6 +1483,7 @@ "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", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index fb6585b365..b0bd2d0a47 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1692,6 +1692,7 @@ "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}}", diff --git a/resources/Resources.php b/resources/Resources.php index 9a7b9e8362..da0112258f 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -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' => [ diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less index 87f257bfd9..4338b0b746 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.ChangesListWrapperWidget.less @@ -28,7 +28,6 @@ } &-results { - width: 35em; margin: 5em auto; &-noresult, @@ -192,3 +191,14 @@ .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 index 0000000000..d3b4a2b615 --- /dev/null +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterTagMultiselectWidgetMobile.less @@ -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; + } + } + } +} diff --git a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less index 949980d75e..4e7d02d23e 100644 --- a/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less +++ b/resources/src/mediawiki.rcfilters/styles/mw.rcfilters.ui.FilterWrapperWidget.less @@ -10,8 +10,38 @@ } &-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 + } + } + } } diff --git a/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js b/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js index a0c0d80c5d..ce869e3d23 100644 --- a/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/ChangesLimitPopupWidget.js @@ -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 ); }; diff --git a/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js index 3429590294..a50cd0ed60 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterTagMultiselectWidget.js @@ -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( - $( '
' ) - .addClass( 'mw-rcfilters-ui-table' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-row' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' ) - .append( - $( '
' ) - .addClass( 'mw-rcfilters-ui-cell' ) - .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' ) - .append( this.input.$element ), - $( '
' ) - .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( + $( '
' ) + .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( + $( '
' ) + .addClass( 'mw-rcfilters-ui-table' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-row' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views' ) + .append( + $( '
' ) + .addClass( 'mw-rcfilters-ui-cell' ) + .addClass( 'mw-rcfilters-ui-filterTagMultiselectWidget-views-input' ) + .append( this.input.$element ), + $( '
' ) + .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(); + } + }; /** diff --git a/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js b/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js index a0f098c2e6..5893a6ca67 100644 --- a/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/FilterWrapperWidget.js @@ -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 = $( '
' ) - .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 diff --git a/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js b/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js index 1e7502018d..465d5b9231 100644 --- a/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js +++ b/resources/src/mediawiki.rcfilters/ui/MenuSelectWidget.js @@ -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 $( '
' ) .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 = { -- 2.20.1