Scroll into view support

Objectives:

* Scroll when needed to show highlighted (with keyboard) or selected (by
  any means) options in select widgets
* Allow clipping and automatic scrolling for certain elements when they
  are otherwise going to be rendered partially out of view

Changes:

*.php
* Add links to new file

ve.ui.Widget.css, ve.ui.Dialog.css
* Removed unneeded x-axis overflow rules

ve.ui.ClippableElement.js, ve.ui.Element.css
* New mixin, adds visible area clipping support to an element

ve.ui.PopupToolGroup.js, ve.ui.MenuWidget.js
* Mixin clippable element

ve.ui.OptionWidget.js, ve.ui.OutlineItemWidget.js
* Add scroll-into-view configuration for option widgets

ve.ui.SearchWidget.js
* Scroll items into view when highlighting with keyboard

ve.Element.js
* Add getBorders, getDimensions, getClosestScrollableContainer and
  scrollIntoView static methods
* Add getClosestScrollableElementContainer and scrollElementIntoView
  methods

Bug: 53610
Change-Id: Ie21faa973a68f517c7cfce8bd879b5317f536365
This commit is contained in:
Trevor Parscal 2013-09-09 17:27:49 -07:00 committed by James D. Forrester
parent 4c12f1841a
commit 143b086c74
13 changed files with 360 additions and 14 deletions

View file

@ -418,6 +418,7 @@ $wgResourceModules += array(
// ui
've/ui/ve.ui.js',
've/ui/elements/ve.ui.ClippableElement.js',
've/ui/elements/ve.ui.LabeledElement.js',
've/ui/elements/ve.ui.IconedElement.js',
've/ui/elements/ve.ui.GroupElement.js',

View file

@ -220,6 +220,7 @@ $html = file_get_contents( $page );
<script src="../../modules/ve/ce/annotations/ve.ce.LinkAnnotation.js"></script>
<script src="../../modules/ve/ce/annotations/ve.ce.TextStyleAnnotation.js"></script>
<script src="../../modules/ve/ui/ve.ui.js"></script>
<script src="../../modules/ve/ui/elements/ve.ui.ClippableElement.js"></script>
<script src="../../modules/ve/ui/elements/ve.ui.LabeledElement.js"></script>
<script src="../../modules/ve/ui/elements/ve.ui.IconedElement.js"></script>
<script src="../../modules/ve/ui/elements/ve.ui.GroupElement.js"></script>

View file

@ -310,7 +310,10 @@
.ve-ui-mwParameterSearchWidget .ve-ui-selectWidget {
position: relative;
width: 100%;
overflow: hidden;
}
.ve-ui-mwParameterSearchWidget .ve-ui-searchWidget-results {
overflow-y: hidden;
}
/* ve.ui.MWParameterResultWidget */

View file

@ -165,6 +165,7 @@
<script src="../../ve/ce/annotations/ve.ce.LinkAnnotation.js"></script>
<script src="../../ve/ce/annotations/ve.ce.TextStyleAnnotation.js"></script>
<script src="../../ve/ui/ve.ui.js"></script>
<script src="../../ve/ui/elements/ve.ui.ClippableElement.js"></script>
<script src="../../ve/ui/elements/ve.ui.LabeledElement.js"></script>
<script src="../../ve/ui/elements/ve.ui.IconedElement.js"></script>
<script src="../../ve/ui/elements/ve.ui.GroupElement.js"></script>

View file

@ -0,0 +1,121 @@
/*!
* VisualEditor UserInterface ClippableElement class.
*
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* Clippable element.
*
* @class
* @abstract
*
* @constructor
* @param {jQuery} $clippable Group element
*/
ve.ui.ClippableElement = function VeUiClippableElement( $clippable ) {
// Properties
this.$clippable = $clippable;
this.clipping = false;
this.clipped = false;
this.$clippableContainer = null;
this.$clippableWindow = null;
this.onClippableContainerScrollHandler = ve.bind( this.clip, this );
this.onClippableWindowResizeHandler = ve.bind( this.clip, this );
// Initialization
this.$clippable.addClass( 've-ui-clippableElement-clippable' );
};
/* Methods */
/**
* Set clipping.
*
* @method
* @param {boolean} value Enable clipping
* @chainable
*/
ve.ui.ClippableElement.prototype.setClipping = function ( value ) {
value = !!value;
if ( this.clipping !== value ) {
this.clipping = value;
if ( this.clipping ) {
this.$clippableContainer = this.$$( this.getClosestScrollableElementContainer() )
.on( 'scroll', this.onClippableContainerScrollHandler );
this.$clippableWindow = this.$$( this.getElementWindow() )
.on( 'resize', this.onClippableWindowResizeHandler );
// Initial clip after visible
setTimeout( ve.bind( this.clip, this ) );
} else {
this.$clippableContainer.off( 'scroll', this.onClippableContainerScrollHandler );
this.$clippableContainer = null;
this.$clippableWindow.off( 'resize', this.onClippableWindowResizeHandler );
this.$clippableWindow = null;
}
}
return this;
};
/**
* Check if the element will be clipped to fit the visible area of the nearest scrollable container.
*
* @method
* @return {boolean} Element will be clipped to the visible area
*/
ve.ui.ClippableElement.prototype.isClipping = function () {
return this.clipping;
};
/**
* Check if the bottom or right of the element is being clipped by the nearest scrollable container.
*
* @method
* @return {boolean} Part of the element is being clipped
*/
ve.ui.ClippableElement.prototype.isClipped = function () {
return this.clipped;
};
/**
* Clip element to visible boundaries and allow scrolling when needed.
*
* Element will be clipped the bottom or right of the element is within 10px of the edge of, or
* overlapped by, the visible area of the nearest scrollable container.
*
* @method
* @chainable
*/
ve.ui.ClippableElement.prototype.clip = function () {
var buffer = 10,
cOffset = this.$clippable.offset(),
ccOffset = this.$clippableContainer.offset() || { 'top': 0, 'left': 0 },
ccHeight = this.$clippableContainer.innerHeight() - buffer,
ccWidth = this.$clippableContainer.innerWidth() - buffer,
scrollTop = this.$clippableContainer.scrollTop(),
scrollLeft = this.$clippableContainer.scrollLeft(),
desiredWidth = ( ccOffset.left + scrollLeft + ccWidth ) - cOffset.left,
desiredHeight = ( ccOffset.top + scrollTop + ccHeight ) - cOffset.top,
naturalWidth = this.$clippable.prop( 'scrollWidth' ),
naturalHeight = this.$clippable.prop( 'scrollHeight' ),
clipWidth = desiredWidth < naturalWidth,
clipHeight = desiredHeight < naturalHeight;
if ( clipWidth ) {
this.$clippable.css( { 'overflow-x': 'auto', 'width': desiredWidth } );
} else {
this.$clippable.css( { 'overflow-x': '', 'width': '' } );
}
if ( clipHeight ) {
this.$clippable.css( { 'overflow-y': 'auto', 'height': desiredHeight } );
} else {
this.$clippable.css( { 'overflow-y': '', 'height': '' } );
}
this.clipped = clipWidth || clipHeight;
return this;
};

View file

@ -168,7 +168,6 @@
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
}
.ve-ui-pagedDialog-pagesPanel .ve-ui-panelLayout-scrollable {

View file

@ -10,3 +10,9 @@
.ve-ui-labeledElement-label {
display: block;
}
.ve-ui-clippableElement-clippable {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}

View file

@ -13,6 +13,7 @@
* @extends ve.ui.ToolGroup
* @mixins ve.ui.IconedElement
* @mixins ve.ui.LabeledElement
* @mixins ve.ui.ClippableElement
*
* @constructor
* @param {ve.ui.Toolbar} toolbar
@ -28,12 +29,15 @@ ve.ui.PopupToolGroup = function VeUiPopupToolGroup( toolbar, config ) {
// Mixin constructors
ve.ui.IconedElement.call( this, this.$$( '<span>' ), config );
ve.ui.LabeledElement.call( this, this.$$( '<span>' ) );
this.$handle = this.$$( '<span>' );
ve.ui.ClippableElement.call( this, this.$group );
// Properties
this.active = false;
this.dragging = false;
this.onBlurHandler = ve.bind( this.onBlur, this );
this.$handle = this.$$( '<span>' );
// Events
this.$handle.on( {
'mousedown': ve.bind( this.onHandleMouseDown, this ),
'mouseup': ve.bind( this.onHandleMouseUp, this )
@ -55,6 +59,7 @@ ve.inheritClass( ve.ui.PopupToolGroup, ve.ui.ToolGroup );
ve.mixinClass( ve.ui.PopupToolGroup, ve.ui.IconedElement );
ve.mixinClass( ve.ui.PopupToolGroup, ve.ui.LabeledElement );
ve.mixinClass( ve.ui.PopupToolGroup, ve.ui.ClippableElement );
/* Static Properties */
@ -125,9 +130,11 @@ ve.ui.PopupToolGroup.prototype.setActive = function ( value ) {
if ( this.active !== value ) {
this.active = value;
if ( value ) {
this.setClipping( true );
this.$.addClass( 've-ui-popupToolGroup-active' );
this.getElementDocument().addEventListener( 'mouseup', this.onBlurHandler, true );
} else {
this.setClipping( false );
this.$.removeClass( 've-ui-popupToolGroup-active' );
this.getElementDocument().removeEventListener( 'mouseup', this.onBlurHandler, true );
}

View file

@ -10,6 +10,7 @@
*
* @class
* @extends ve.ui.SelectWidget
* @mixins ve.ui.ClippableElement
*
* @constructor
* @param {Object} [config] Config options
@ -22,13 +23,16 @@ ve.ui.MenuWidget = function VeUiMenuWidget( config ) {
// Parent constructor
ve.ui.SelectWidget.call( this, config );
// Mixin constructor
ve.ui.ClippableElement.call( this, this.$group );
// Properties
this.newItems = [];
this.$input = config.input ? config.input.$input : null;
this.$previousFocus = null;
this.isolated = !config.input;
this.visible = false;
this.keydownHandler = ve.bind( this.onKeyDown, this );
this.onKeyDownHandler = ve.bind( this.onKeyDown, this );
// Initialization
this.$.hide().addClass( 've-ui-menuWidget' );
@ -38,6 +42,8 @@ ve.ui.MenuWidget = function VeUiMenuWidget( config ) {
ve.inheritClass( ve.ui.MenuWidget, ve.ui.SelectWidget );
ve.mixinClass( ve.ui.MenuWidget, ve.ui.ClippableElement );
/* Methods */
/**
@ -47,7 +53,8 @@ ve.inheritClass( ve.ui.MenuWidget, ve.ui.SelectWidget );
* @param {jQuery.Event} e Key down event
*/
ve.ui.MenuWidget.prototype.onKeyDown = function ( e ) {
var handled = false,
var nextItem,
handled = false,
highlightItem = this.getHighlightedItem();
if ( !this.disabled && this.visible ) {
@ -60,11 +67,11 @@ ve.ui.MenuWidget.prototype.onKeyDown = function ( e ) {
handled = true;
break;
case ve.Keys.UP:
this.highlightItem( this.getRelativeSelectableItem( highlightItem, -1 ) );
nextItem = this.getRelativeSelectableItem( highlightItem, -1 );
handled = true;
break;
case ve.Keys.DOWN:
this.highlightItem( this.getRelativeSelectableItem( highlightItem, 1 ) );
nextItem = this.getRelativeSelectableItem( highlightItem, 1 );
handled = true;
break;
case ve.Keys.ESCAPE:
@ -75,6 +82,12 @@ ve.ui.MenuWidget.prototype.onKeyDown = function ( e ) {
handled = true;
break;
}
if ( nextItem ) {
this.highlightItem( nextItem );
nextItem.scrollElementIntoView();
}
if ( handled ) {
e.preventDefault();
e.stopPropagation();
@ -93,7 +106,6 @@ ve.ui.MenuWidget.prototype.isVisible = function () {
return this.visible;
};
/**
* Bind keydown listener
*
@ -101,10 +113,10 @@ ve.ui.MenuWidget.prototype.isVisible = function () {
*/
ve.ui.MenuWidget.prototype.bindKeydownListener = function () {
if ( this.$input ) {
this.$input.on( 'keydown', this.keydownHandler );
this.$input.on( 'keydown', this.onKeyDownHandler );
} else {
// Capture menu navigation keys
window.addEventListener( 'keydown', this.keydownHandler, true );
window.addEventListener( 'keydown', this.onKeyDownHandler, true );
}
};
@ -117,7 +129,7 @@ ve.ui.MenuWidget.prototype.unbindKeydownListener = function () {
if ( this.$input ) {
this.$input.off( 'keydown' );
} else {
window.removeEventListener( 'keydown', this.keydownHandler, true );
window.removeEventListener( 'keydown', this.onKeyDownHandler, true );
}
};
@ -203,6 +215,8 @@ ve.ui.MenuWidget.prototype.show = function () {
}
this.newItems = [];
}
this.setClipping( true );
}
return this;
@ -224,5 +238,7 @@ ve.ui.MenuWidget.prototype.hide = function () {
this.$previousFocus = null;
}
this.setClipping( false );
return this;
};

View file

@ -68,6 +68,8 @@ ve.ui.OptionWidget.static.selectable = true;
ve.ui.OptionWidget.static.highlightable = true;
ve.ui.OptionWidget.static.scrollIntoViewOnSelect = false;
/* Methods */
/**
@ -122,6 +124,9 @@ ve.ui.OptionWidget.prototype.setSelected = function ( state ) {
this.selected = !!state;
if ( this.selected ) {
this.$.addClass( 've-ui-optionWidget-selected' );
if ( this.constructor.static.scrollIntoViewOnSelect ) {
this.scrollElementIntoView();
}
} else {
this.$.removeClass( 've-ui-optionWidget-selected' );
}

View file

@ -41,6 +41,8 @@ ve.inheritClass( ve.ui.OutlineItemWidget, ve.ui.OptionWidget );
ve.ui.OutlineItemWidget.static.highlightable = false;
ve.ui.OutlineItemWidget.static.scrollIntoViewOnSelect = true;
ve.ui.OutlineItemWidget.static.levelClass = 've-ui-outlineItemWidget-level-';
ve.ui.OutlineItemWidget.static.levels = 3;

View file

@ -82,7 +82,7 @@ ve.inheritClass( ve.ui.SearchWidget, ve.ui.Widget );
* @param {jQuery.Event} e Key down event
*/
ve.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
var highlightedItem,
var highlightedItem, nextItem,
dir = e.which === ve.Keys.DOWN ? 1 : ( e.which === ve.Keys.UP ? -1 : 0 );
if ( dir ) {
@ -90,7 +90,9 @@ ve.ui.SearchWidget.prototype.onQueryKeydown = function ( e ) {
if ( !highlightedItem ) {
highlightedItem = this.results.getSelectedItem();
}
this.results.highlightItem( this.results.getRelativeSelectableItem( highlightedItem, dir ) );
nextItem = this.results.getRelativeSelectableItem( highlightedItem, dir );
this.results.highlightItem( nextItem );
nextItem.scrollElementIntoView();
}
};

View file

@ -107,7 +107,6 @@ ve.Element.getWindow = function ( context ) {
return doc.parentWindow || doc.defaultView;
};
/**
* Get the offset between two frames.
*
@ -167,6 +166,169 @@ ve.Element.getRelativePosition = function ( $from, $to ) {
return { 'top': from.top - to.top, 'left': from.left - to.left };
};
/**
* Get element border sizes.
*
* @param {HTMLElement} el Element to measure
* @return {Object} Dimensions object with `top`, `left`, `bottom` and `right` properties
*/
ve.Element.getBorders = function ( el ) {
var doc = el.ownerDocument,
win = doc.parentWindow || doc.defaultView,
style = win && win.getComputedStyle ?
win.getComputedStyle( el, null ) : el.currentStyle,
loc = win && win.getComputedStyle ? true : false,
$el = $( el ),
top = parseFloat( loc ? style.borderTopWidth : $el.css( 'borderTopWidth' ) ) || 0,
left = parseFloat( loc ? style.borderLeftWidth : $el.css( 'borderLeftWidth' ) ) || 0,
bottom = parseFloat( loc ? style.borderBottomWidth : $el.css( 'borderBottomWidth' ) ) || 0,
right = parseFloat( loc ? style.borderRightWidth : $el.css( 'borderRightWidth' ) ) || 0;
return {
'top': Math.round( top ),
'left': Math.round( left ),
'bottom': Math.round( bottom ),
'right': Math.round( right )
};
};
/**
* Get dimensions of an element or window.
*
* @param {HTMLElement|Window} el Element to measure
* @return {Object} Dimensions object with `borders`, `scroll`, `scrollbar` and `rect` properties
*/
ve.Element.getDimensions = function ( el ) {
var $el, $win,
doc = el.ownerDocument || el.document,
win = doc.parentWindow || doc.defaultView;
if ( win === el || el === doc.documentElement ) {
$win = $( win );
return {
'borders': { 'top': 0, 'left': 0, 'bottom': 0, 'right': 0 },
'scroll': {
'top': $win.scrollTop(),
'left': $win.scrollLeft()
},
'scrollbar': { 'right': 0, 'bottom': 0 },
'rect': {
'top': 0,
'left': 0,
'bottom': $win.innerHeight(),
'right': $win.innerWidth()
}
};
} else {
$el = $( el );
return {
'borders': this.getBorders( el ),
'scroll': {
'top': $el.scrollTop(),
'left': $el.scrollLeft()
},
'scrollbar': {
'right': $el.innerWidth() - el.clientWidth,
'bottom': $el.innerHeight() - el.clientHeight
},
'rect': el.getBoundingClientRect()
};
}
};
/**
* Get closest scrollable container.
*
* Traverses up until either a scrollable element or the root is reached, in which case the window
* will be returned.
*
* @static
* @param {HTMLElement} el Element to find scrollable container for
* @param {string} [dimension] Dimension of scrolling to look for; `x`, `y` or omit for either
* @return {HTMLElement|Window} Closest scrollable container
*/
ve.Element.getClosestScrollableContainer = function ( el, dimension ) {
var i, val,
props = [ 'overflow' ],
$parent = $( el ).parent();
if ( dimension === 'x' || dimension === 'y' ) {
props.push( 'overflow-' + dimension );
}
while ( $parent ) {
if ( $parent[0] === el.ownerDocument.documentElement ) {
break;
}
i = props.length;
while ( i-- ) {
val = $parent.css( props[i] );
if ( val === 'auto' || val === 'scroll' ) {
return $parent[0];
}
}
$parent = $parent.parent();
}
return this.getWindow( el );
};
/**
* Scroll element into view
*
* @static
* @param {HTMLElement} el Element to scroll into view
* @param {Object} [config] Configuration config
* @param {string} [config.duration] jQuery animation duration value
* @param {string} [config.direction] Scroll in only one direction, e.g. 'x' or 'y', omit
* to scroll in both directions
* @param {Function} [config.complete] Function to call when scrolling completes
*/
ve.Element.scrollIntoView = function ( el, config ) {
// Configuration initialization
config = config || {};
var anim = {},
callback = typeof config.complete === 'function' && config.complete,
sc = this.getClosestScrollableContainer( el, config.direction ),
$sc = $( sc ),
eld = this.getDimensions( el ),
scd = this.getDimensions( sc ),
rel = {
'top': eld.rect.top - ( scd.rect.top + scd.borders.top ),
'bottom': scd.rect.bottom - scd.borders.bottom - scd.scrollbar.bottom - eld.rect.bottom,
'left': eld.rect.left - ( scd.rect.left + scd.borders.left ),
'right': scd.rect.right - scd.borders.right - scd.scrollbar.right - eld.rect.right
};
if ( !config.direction || config.direction === 'y' ) {
if ( rel.top < 0 ) {
anim.scrollTop = scd.scroll.top + rel.top;
} else if ( rel.top > 0 && rel.bottom < 0 ) {
anim.scrollTop = scd.scroll.top + Math.min( rel.top, -rel.bottom );
}
}
if ( !config.direction || config.direction === 'x' ) {
if ( rel.left < 0 ) {
anim.scrollLeft = scd.scroll.left + rel.left;
} else if ( rel.left > 0 && rel.right < 0 ) {
anim.scrollLeft = scd.scroll.left + Math.min( rel.left, -rel.right );
}
}
if ( !ve.isEmptyObject( anim ) ) {
$sc.stop( true ).animate( anim, config.duration || 'fast' );
if ( callback ) {
$sc.queue( function ( next ) {
callback();
next();
} );
}
} else {
if ( callback ) {
callback();
}
}
};
/* Methods */
/**
@ -197,3 +359,23 @@ ve.Element.prototype.getElementDocument = function () {
ve.Element.prototype.getElementWindow = function () {
return ve.Element.getWindow( this.$ );
};
/**
* Get closest scrollable container.
*
* @method
* @see #static-method-getClosestScrollableContainer
*/
ve.Element.prototype.getClosestScrollableElementContainer = function () {
return ve.Element.getClosestScrollableContainer( this.$[0] );
};
/**
* Scroll element into view
*
* @method
* @see #static-method-scrollIntoView
*/
ve.Element.prototype.scrollElementIntoView = function ( config ) {
return ve.Element.scrollIntoView( this.$[0], config );
};