Redesign Special:Preferences for mobile

- Added a hook that checks if the preferences should have a mobile or desktop layout
- Added descriptions to preference tabs, which now display as a stack layout in mobile
- Added a new mobile JS file to control Special:Preferences when in mobile view
- Built the mobile interface in the preferences form

Bug: T311717
Change-Id: I468481b66bf96880d1779cd11a46e18745e2c894
This commit is contained in:
suecarmol 2022-09-20 20:00:14 -05:00 committed by Jdlrobson
parent fc88070dbb
commit 0ffdf80425
9 changed files with 416 additions and 129 deletions

View file

@ -0,0 +1,25 @@
<?php
namespace MediaWiki\Hook;
use Skin;
/**
* This is a hook handler interface, see docs/Hooks.md.
* Use the hook name "PreferencesGetLayout" to register handlers implementing this interface.
*
* @stable to implement
* @ingroup Hooks
*/
interface PreferencesGetLayoutHook {
/**
* Use the hook to check if the preferences will have a mobile or desktop layout.
*
* @since 1.40
* @param bool &$useMobileLayout a boolean which will indicate whether to use
* a mobile layout or not
* @param Skin $skin the skin being used
* @return bool|void True or no return value to continue or false to abort
*/
public function onPreferencesGetLayout( &$useMobileLayout, $skin );
}

View file

@ -304,6 +304,7 @@ class HookRunner implements
\MediaWiki\Hook\ParserTestTablesHook,
\MediaWiki\Hook\PasswordPoliciesForUserHook,
\MediaWiki\Hook\PostLoginRedirectHook,
\MediaWiki\Hook\PreferencesGetLayoutHook,
\MediaWiki\Hook\PreferencesGetLegendHook,
\MediaWiki\Hook\PrefsEmailAuditHook,
\MediaWiki\Hook\ProtectionForm__buildFormHook,
@ -3087,6 +3088,13 @@ class HookRunner implements
);
}
public function onPreferencesGetLayout( &$useMobileLayout, $skin ) {
return $this->container->run(
'PreferencesGetLayout',
[ &$useMobileLayout, $skin ]
);
}
public function onPreferencesGetLegend( $form, $key, &$legend ) {
return $this->container->run(
'PreferencesGetLegend',

View file

@ -155,6 +155,150 @@ class PreferencesFormOOUI extends OOUIHTMLForm {
* @return string
*/
public function getBody() {
$out = $this->getOutput();
$this->getHookRunner()->onPreferencesGetLayout( $useMobileLayout, $out->getSkin() );
$out->addJsConfigVars( [ 'wgSpecialPreferencesUseMobileLayout' => $useMobileLayout ] );
if ( $useMobileLayout ) {
// Import the icons used in the mobile view
$out->addModuleStyles(
[
'oojs-ui.styles.icons-user',
'oojs-ui.styles.icons-editing-core',
'oojs-ui.styles.icons-editing-advanced',
'oojs-ui.styles.icons-wikimediaui',
'oojs-ui.styles.icons-content',
'oojs-ui.styles.icons-moderation',
'oojs-ui.styles.icons-interactions',
'oojs-ui.styles.icons-movement',
'oojs-ui.styles.icons-wikimedia',
'oojs-ui.styles.icons-media',
'oojs-ui.styles.icons-accessibility',
'oojs-ui.styles.icons-layout',
]
);
$form = $this->createMobilePreferencesForm();
} else {
$form = $this->createDesktopPreferencesForm();
}
$header = $this->formatFormHeader();
return $header . $form;
}
/**
* Get the "<legend>" for a given section key. Normally this is the
* prefs-$key message but we'll allow extensions to override it.
* @param string $key
* @return string
*/
public function getLegend( $key ) {
$legend = parent::getLegend( $key );
$this->getHookRunner()->onPreferencesGetLegend( $this, $key, $legend );
return $legend;
}
/**
* Get the keys of each top level preference section.
* @return string[] List of section keys
*/
public function getPreferenceSections() {
return array_keys( array_filter( $this->mFieldTree, 'is_array' ) );
}
/**
* Create the preferences form for a mobile layout.
* @return string
*/
private function createMobilePreferencesForm() {
$prefPanels = [];
foreach ( $this->mFieldTree as $key => $val ) {
if ( !is_array( $val ) ) {
wfDebug( __METHOD__ . " encountered a field not attached to a section: '$key'" );
continue;
}
$label = $this->getLegend( $key );
$content =
$this->getHeaderHtml( $key ) .
$this->displaySection(
$val,
"",
"mw-prefsection-$key-"
) .
$this->getFooterHtml( $key );
$prefPanel = new OOUI\PanelLayout( [
'expanded' => false,
'content' => [],
'framed' => false,
'classes' => [ 'mw-mobile-preferences-option' ]
] );
$iconHeaderDiv = ( new OOUI\Tag( 'div' ) )
->addClasses( [ 'mw-prefs-header-container' ] );
$prefTitle = ( new OOUI\Tag( 'h5' ) )->appendContent( $label )->addClasses( [ 'prefs-title' ] );
$iconHeaderDiv->appendContent( $prefTitle );
$prefPanel->appendContent( $iconHeaderDiv );
$prefDescriptionMsg = $this->msg( "prefs-description-" . $key );
$prefDescription = $prefDescriptionMsg->exists() ? $prefDescriptionMsg->text() : "";
$prefPanel->appendContent( ( new OOUI\Tag( 'p' ) )
->appendContent( $prefDescription )
->addClasses( [ 'mw-prefs-description' ] )
);
$contentDiv = ( new OOUI\Tag( 'div' ) )->addClasses( [ 'mw-prefs-hidden' ] );
$contentDiv->setAttributes( [
'id' => 'mw-prefs-option-' . $key . '-content'
] );
$contentHeader = ( new OOUI\Tag( 'div' ) )->addClasses( [ 'mw-prefs-content-header' ] );
$contentHeaderBackButton = new OOUI\IconWidget( [
'icon' => 'previous',
'label' => $this->msg( "prefs-back-label" ),
'title' => $this->msg( "prefs-back-title" ),
'classes' => [ 'mw-prefs-header-icon' ],
] );
$contentHeaderBackButton->setAttributes( [
'id' => 'mw-prefs-option-' . $key . '-back-button',
] );
$contentHeaderTitle = ( new OOUI\Tag( 'h5' ) )
->appendContent( $label )->addClasses( [ 'mw-prefs-header-title' ] );
$formContent = new OOUI\Widget( [
'content' => new OOUI\HtmlSnippet( $content )
] );
$hiddenForm = ( new OOUI\Tag( 'div' ) )->appendContent( $formContent );
$contentHeader->appendContent( $contentHeaderBackButton );
$contentHeader->appendContent( $contentHeaderTitle );
$contentDiv->appendContent( $contentHeader );
$contentDiv->appendContent( $hiddenForm );
$prefPanel->appendContent( $contentDiv );
$prefPanel->setAttributes( [
'id' => 'mw-prefs-option-' . $key,
] );
$prefPanel->setInfusable( true );
$prefPanels[] = $prefPanel;
}
$form = new OOUI\StackLayout( [
'items' => $prefPanels,
'continuous' => true,
'expanded' => false,
'classes' => [ 'mw-mobile-preferences-container' ]
] );
$form->setAttributes( [
'id' => 'mw-prefs-container',
] );
$form->setInfusable( true );
return $form;
}
/**
* Create the preferences form for a desktop layout.
* @return string
*/
private function createDesktopPreferencesForm() {
$tabPanels = [];
foreach ( $this->mFieldTree as $key => $val ) {
if ( !is_array( $val ) ) {
@ -163,13 +307,13 @@ class PreferencesFormOOUI extends OOUIHTMLForm {
}
$label = $this->getLegend( $key );
$content =
$this->getHeaderText( $key ) .
$this->getHeaderHtml( $key ) .
$this->displaySection(
$val,
"",
"mw-prefsection-$key-"
) .
$this->getFooterText( $key );
$this->getFooterHtml( $key );
$tabPanels[] = new OOUI\TabPanelLayout( 'mw-prefsection-' . $key, [
'classes' => [ 'mw-htmlform-autoinfuse-lazy' ],
@ -197,7 +341,6 @@ class PreferencesFormOOUI extends OOUIHTMLForm {
] );
$indexLayout->addTabPanels( $tabPanels );
$header = $this->formatFormHeader();
$form = new OOUI\PanelLayout( [
'framed' => true,
'expanded' => false,
@ -205,26 +348,6 @@ class PreferencesFormOOUI extends OOUIHTMLForm {
'content' => $indexLayout
] );
return $header . $form;
}
/**
* Get the "<legend>" for a given section key. Normally this is the
* prefs-$key message but we'll allow extensions to override it.
* @param string $key
* @return string
*/
public function getLegend( $key ) {
$legend = parent::getLegend( $key );
$this->getHookRunner()->onPreferencesGetLegend( $this, $key, $legend );
return $legend;
}
/**
* Get the keys of each top level preference section.
* @return string[] List of section keys
*/
public function getPreferenceSections() {
return array_keys( array_filter( $this->mFieldTree, 'is_array' ) );
return $form;
}
}

View file

@ -1108,6 +1108,15 @@
"recentchangesdays": "Days to show in recent changes:",
"recentchangesdays-max": "Maximum $1 {{PLURAL:$1|day|days}}",
"recentchangescount": "Number of edits to show in recent changes, page histories, and in logs, by default:",
"prefs-back-label": "Back",
"prefs-back-title": "Back to preferences",
"prefs-description-personal": "Control how you appear, connect, and communicate.",
"prefs-description-rendering": "Configure skin, size, and reading options.",
"prefs-description-editing": "Customize how you make, track, and review edits.",
"prefs-description-rc": "Customise the recent changes feed.",
"prefs-description-watchlist": "Manage and personalize the list of pages you track.",
"prefs-description-searchoptions": "Choose how autocomplete and results work.",
"prefs-description-misc": "Customize the table of contents.",
"prefs-help-recentchangescount": "Maximum number: 1000",
"prefs-help-watchlist-token2": "This is the secret key to the web feed of your watchlist.\nAnyone who knows it will be able to read your watchlist, so do not share it.\nIf you need to, [[Special:ResetTokens|you can reset it]].",
"prefs-help-tokenmanagement": "You can see and reset the secret key for your account that can access the Web feed of your watchlist. Anyone who knows the key will be able to read your watchlist, so do not share it.",

View file

@ -1349,6 +1349,15 @@
"recentchangesdays": "Used in [[Special:Preferences]], tab \"Recent changes\".",
"recentchangesdays-max": "Shown as hint in [[Special:Preferences]], tab \"Recent changes\". Parameters:\n* $1 - number of days\nSee also:\n* {{msg-mw|Prefs-watchlist-days-max}}",
"recentchangescount": "Used in [[Special:Preferences]], tab \"Recent changes\".",
"prefs-back-label": "Used in [[Special:Preferences]] as a label for a back button",
"prefs-back-title": "Used in [[Special:Preferences]] as the title for a back button",
"prefs-description-personal": "Used in [[Special:Preferences]] for mobile to describe the User Profile section. ",
"prefs-description-rendering": "Used in [[Special:Preferences]] for mobile to describe the Appearance section. ",
"prefs-description-editing": "Used in [[Special:Preferences]] for mobile to describe the Editing section. ",
"prefs-description-rc": "Used in [[Special:Preferences]] for mobile to describe the Recent Changes section. ",
"prefs-description-watchlist": "Used in [[Special:Preferences]] for mobile to describe the Watchlist section. ",
"prefs-description-searchoptions": "Used in [[Special:Preferences]] for mobile to describe the Search section. ",
"prefs-description-misc": "Used in [[Special:Preferences]] for mobile to describe the Misc section.",
"prefs-help-recentchangescount": "Used in [[Special:Preferences]], tab \"Recent changes\".",
"prefs-help-watchlist-token2": "Used in [[Special:Preferences]], tab Watchlist. (Formerly in {{msg-mw|prefs-help-watchlist-token}}.)",
"prefs-help-tokenmanagement": "Used in [[Special:Preferences]], Watchlist tab.",

View file

@ -2284,6 +2284,7 @@ return [
'resources/src/mediawiki.special.preferences.ooui/confirmClose.js',
'resources/src/mediawiki.special.preferences.ooui/convertmessagebox.js',
'resources/src/mediawiki.special.preferences.ooui/editfont.js',
'resources/src/mediawiki.special.preferences.ooui/mobile.js',
'resources/src/mediawiki.special.preferences.ooui/skinPrefs.js',
'resources/src/mediawiki.special.preferences.ooui/signature.js',
'resources/src/mediawiki.special.preferences.ooui/tabs.js',

View file

@ -0,0 +1,49 @@
/*!
* JavaScript for Special:Preferences: Tab navigation.
*/
( function () {
var useMobileLayout = mw.config.get( 'wgSpecialPreferencesUseMobileLayout' ) === null ? false : mw.config.get( 'wgSpecialPreferencesUseMobileLayout' );
// New for T311717: Check if a user will display the mobile layout
if ( useMobileLayout ) {
$( function () {
var $prefContent, prefContentId;
var options = OO.ui.infuse( $( '.mw-mobile-preferences-container' ) );
var $preferencesContainer = $( '#preferences' );
var $prefOptionsContainer = $( '#mw-prefs-container' );
function triggerPreferenceMenu( elementId ) {
prefContentId = elementId + '-content';
$prefContent = $( '#' + prefContentId );
$prefContent.removeClass( 'mw-prefs-hidden' );
$prefContent.attr( 'style', 'display:block;' );
$prefOptionsContainer.addClass( 'mw-prefs-hidden' );
$prefOptionsContainer.removeAttr( 'style' );
$preferencesContainer.prepend( $prefContent );
// Snippet based on https://stackoverflow.com/a/58944651/4612594
// This prevents the page from scrolling down to where it was previously.
if ( 'scrollRestoration' in history ) {
history.scrollRestoration = 'manual';
}
window.scrollTo( 0, 0 );
}
function triggerBackToOptions( elementId ) {
prefContentId = elementId + '-content';
$prefContent = $( '#' + prefContentId );
$prefOptionsContainer.removeClass( 'mw-prefs-hidden' );
$prefOptionsContainer.attr( 'style', 'display:block;' );
$prefContent.addClass( 'mw-prefs-hidden' );
$prefContent.removeAttr( 'style' );
$preferencesContainer.prepend( $prefOptionsContainer );
}
// Add a click event for each preference option
options.items.forEach( function ( element ) {
$( '#' + element.elementId ).on( 'click', function () {
triggerPreferenceMenu( element.elementId );
} );
var backButtonId = '#' + element.elementId + '-back-button';
$( backButtonId ).on( 'click', function () {
triggerBackToOptions( element.elementId );
} );
} );
} );
}
}() );

View file

@ -2,121 +2,124 @@
* JavaScript for Special:Preferences: Tab navigation.
*/
( function () {
$( function () {
// Make sure the accessibility tip is focussable so that keyboard users take notice,
// but hide it by default to reduce visual clutter.
// Make sure it becomes visible when focused.
$( '<div>' ).addClass( 'mw-navigation-hint' )
.text( mw.msg( 'prefs-tabs-navigation-hint' ) )
.attr( {
tabIndex: 0
} )
.insertBefore( '.mw-htmlform-ooui-wrapper' );
var useMobileLayout = mw.config.get( 'wgSpecialPreferencesUseMobileLayout', false );
if ( !useMobileLayout ) {
$( function () {
// Make sure the accessibility tip is focussable so that keyboard users take notice,
// but hide it by default to reduce visual clutter.
// Make sure it becomes visible when focused.
$( '<div>' ).addClass( 'mw-navigation-hint' )
.text( mw.msg( 'prefs-tabs-navigation-hint' ) )
.attr( {
tabIndex: 0
} )
.insertBefore( '.mw-htmlform-ooui-wrapper' );
var tabs = OO.ui.infuse( $( '.mw-prefs-tabs' ) );
var tabs = OO.ui.infuse( $( '.mw-prefs-tabs' ) );
// Support: Chrome
// https://bugs.chromium.org/p/chromium/issues/detail?id=1252507
//
// Infusing the tabs above involves detaching all the tabs' content from the DOM momentarily,
// which causes the :target selector (used in mediawiki.special.preferences.styles.ooui.less)
// not to match anything inside the tabs in Chrome. Twiddling location.href makes it work.
// Only do it when a fragment is present, otherwise the page would be reloaded.
if ( location.href.indexOf( '#' ) !== -1 ) {
// eslint-disable-next-line no-self-assign
location.href = location.href;
}
tabs.$element.addClass( 'mw-prefs-tabs-infused' );
function enhancePanel( panel ) {
if ( !panel.$element.data( 'mw-section-infused' ) ) {
panel.$element.removeClass( 'mw-htmlform-autoinfuse-lazy' );
mw.hook( 'htmlform.enhance' ).fire( panel.$element );
panel.$element.data( 'mw-section-infused', true );
// Support: Chrome
// https://bugs.chromium.org/p/chromium/issues/detail?id=1252507
//
// Infusing the tabs above involves detaching all the tabs' content from the DOM momentarily,
// which causes the :target selector (used in mediawiki.special.preferences.styles.ooui.less)
// not to match anything inside the tabs in Chrome. Twiddling location.href makes it work.
// Only do it when a fragment is present, otherwise the page would be reloaded.
if ( location.href.indexOf( '#' ) !== -1 ) {
// eslint-disable-next-line no-self-assign
location.href = location.href;
}
}
var switchingNoHash;
tabs.$element.addClass( 'mw-prefs-tabs-infused' );
function onTabPanelSet( panel ) {
if ( switchingNoHash ) {
return;
}
// Handle hash manually to prevent jumping,
// therefore save and restore scrollTop to prevent jumping.
var scrollTop = $( window ).scrollTop();
// Changing the hash apparently causes keyboard focus to be lost?
// Save and restore it. This makes no sense though.
var active = document.activeElement;
location.hash = '#' + panel.getName();
if ( active ) {
active.focus();
}
$( window ).scrollTop( scrollTop );
}
tabs.on( 'set', onTabPanelSet );
/**
* @ignore
* @param {string} name The name of a tab
* @param {boolean} [noHash] A hash will be set according to the current
* open section. Use this flag to suppress this.
*/
function switchPrefTab( name, noHash ) {
if ( noHash ) {
switchingNoHash = true;
}
tabs.setTabPanel( name );
enhancePanel( tabs.getCurrentTabPanel() );
if ( noHash ) {
switchingNoHash = false;
}
}
// Jump to correct section as indicated by the hash.
// This function is called onload and onhashchange.
function detectHash() {
var hash = location.hash;
if ( /^#mw-prefsection-[\w]+$/.test( hash ) ) {
mw.storage.session.remove( 'mwpreferences-prevTab' );
switchPrefTab( hash.slice( 1 ) );
} else if ( /^#mw-[\w-]+$/.test( hash ) ) {
var matchedElement = document.getElementById( hash.slice( 1 ) );
var $parentSection = $( matchedElement ).closest( '.mw-prefs-section-fieldset' );
if ( $parentSection.length ) {
mw.storage.session.remove( 'mwpreferences-prevTab' );
// Switch to proper tab and scroll to selected item.
switchPrefTab( $parentSection.attr( 'id' ), true );
matchedElement.scrollIntoView();
function enhancePanel( panel ) {
if ( !panel.$element.data( 'mw-section-infused' ) ) {
panel.$element.removeClass( 'mw-htmlform-autoinfuse-lazy' );
mw.hook( 'htmlform.enhance' ).fire( panel.$element );
panel.$element.data( 'mw-section-infused', true );
}
}
}
$( window ).on( 'hashchange', function () {
var hash = location.hash;
if ( /^#mw-[\w-]+/.test( hash ) ) {
detectHash();
} else if ( hash === '' ) {
switchPrefTab( 'mw-prefsection-personal', true );
var switchingNoHash;
function onTabPanelSet( panel ) {
if ( switchingNoHash ) {
return;
}
// Handle hash manually to prevent jumping,
// therefore save and restore scrollTop to prevent jumping.
var scrollTop = $( window ).scrollTop();
// Changing the hash apparently causes keyboard focus to be lost?
// Save and restore it. This makes no sense though.
var active = document.activeElement;
location.hash = '#' + panel.getName();
if ( active ) {
active.focus();
}
$( window ).scrollTop( scrollTop );
}
} )
// Run the function immediately to select the proper tab on startup.
.trigger( 'hashchange' );
// Restore the active tab after saving the preferences
var previousTab = mw.storage.session.get( 'mwpreferences-prevTab' );
if ( previousTab ) {
switchPrefTab( previousTab, true );
// Deleting the key, the tab states should be reset until we press Save
mw.storage.session.remove( 'mwpreferences-prevTab' );
}
tabs.on( 'set', onTabPanelSet );
/**
* @ignore
* @param {string} name The name of a tab
* @param {boolean} [noHash] A hash will be set according to the current
* open section. Use this flag to suppress this.
*/
function switchPrefTab( name, noHash ) {
if ( noHash ) {
switchingNoHash = true;
}
tabs.setTabPanel( name );
enhancePanel( tabs.getCurrentTabPanel() );
if ( noHash ) {
switchingNoHash = false;
}
}
// Jump to correct section as indicated by the hash.
// This function is called onload and onhashchange.
function detectHash() {
var hash = location.hash;
if ( /^#mw-prefsection-[\w]+$/.test( hash ) ) {
mw.storage.session.remove( 'mwpreferences-prevTab' );
switchPrefTab( hash.slice( 1 ) );
} else if ( /^#mw-[\w-]+$/.test( hash ) ) {
var matchedElement = document.getElementById( hash.slice( 1 ) );
var $parentSection = $( matchedElement ).closest( '.mw-prefs-section-fieldset' );
if ( $parentSection.length ) {
mw.storage.session.remove( 'mwpreferences-prevTab' );
// Switch to proper tab and scroll to selected item.
switchPrefTab( $parentSection.attr( 'id' ), true );
matchedElement.scrollIntoView();
}
}
}
$( window ).on( 'hashchange', function () {
var hash = location.hash;
if ( /^#mw-[\w-]+/.test( hash ) ) {
detectHash();
} else if ( hash === '' ) {
switchPrefTab( 'mw-prefsection-personal', true );
}
} )
// Run the function immediately to select the proper tab on startup.
.trigger( 'hashchange' );
// Restore the active tab after saving the preferences
var previousTab = mw.storage.session.get( 'mwpreferences-prevTab' );
if ( previousTab ) {
switchPrefTab( previousTab, true );
// Deleting the key, the tab states should be reset until we press Save
mw.storage.session.remove( 'mwpreferences-prevTab' );
}
$( '#mw-prefs-form' ).on( 'submit', function () {
var value = tabs.getCurrentTabPanelName();
mw.storage.session.set( 'mwpreferences-prevTab', value );
} );
$( '#mw-prefs-form' ).on( 'submit', function () {
var value = tabs.getCurrentTabPanelName();
mw.storage.session.set( 'mwpreferences-prevTab', value );
} );
} );
}
}() );

View file

@ -1,5 +1,6 @@
@import 'mediawiki.skin.variables.less';
@import 'mediawiki.mixins.less';
@import 'mediawiki.ui/variables';
/* Uses standard message block colors, compare mediawiki.legacy/shared.css */
.mw-email-not-authenticated .oo-ui-labelWidget,
@ -185,3 +186,62 @@
#wpTimeCorrection .oo-ui-textInputWidget {
margin-top: 0.5em;
}
/* T311717 - Styles for Special:Preferences on mobile
These are used when users navigate to Special:Preferences
with params ?useskin=vector&useformat=mobile
*/
.mw-mobile-preferences-option {
cursor: pointer;
padding-top: 0.3125em;
border-bottom: 0.0625em solid @colorGray12;
}
.mw-mobile-preferences-option:hover {
background-color: @colorGray15;
}
.mw-mobile-preferences-option:last-child {
border-bottom: none; // stylelint-disable-line declaration-property-value-disallowed-list
}
.mw-prefs-title {
font-weight: normal;
font-size: 1em;
line-height: 1.25em;
color: @colorGray2;
}
.mw-prefs-description {
font-weight: normal;
font-size: 0.875em;
line-height: 1.25em;
color: @colorGray7;
margin-top: 0 !important; /* stylelint-disable-line declaration-no-important */
}
.mw-prefs-header-container {
display: flex;
}
.mw-prefs-hidden {
display: none;
}
.mw-prefs-content-header {
width: 100%;
height: 3.125em;
display: block;
border-bottom: 1px solid @colorGray12;
box-shadow: 0 0.25em 0.125em -0.1875em rgba( 0, 0, 0, 0.25 );
}
.oo-ui-iconWidget.mw-prefs-header-icon {
color: @colorGray2;
cursor: pointer;
margin: 0.25em 0.5em;
}
.mw-prefs-header-title {
display: inline-flex;
}