mediawiki-extensions-Visual.../modules/ve/ui/widgets/ve.ui.MenuWidget.js
Trevor Parscal a5aeca3ff5 Major UI refactoring and improvements
Objective:

Refactor UI widgets, improve usability and accessibility of menus, general cleanup and style improvements.

Extras:
Fixed documentation in a few other files to make descriptions of jQuery event arguments more consistent, classes inherit correctly, and made use of the @cfg functionality in jsduck.

Changes:

.docs/config.json
* Added window, HTMLDocument, HTMLElement, DocumentFragment and XMLHttpRequest to externals, so jsduck doesn't throw warnings when they are used

demos/ve/index.php, modules/ve/test/index.php, VisualEditor.php
* Moved widgets above tools (since tools use widgets)

demos/ve/index.php
* Refactored widget initialization to use options
* Renamed variables to match widget names

ve.init.mw.ViewPageTarget.css
* Adjusted text sizes to make widgets work normally
* Added margins for buttons in toolbar (since button widgets
don't have any)
* Removed styles for init buttons (button widgets now)

ve.init.mw.ViewPageTarget.js
* Switched to using button widgets (involved moving things around
a bit)

ve.ui.LinkInspector.js, ve.ui.MWLinkInspector.js
* Renamed static property "inputWidget" to
"linkTargetInputWidget" to better reflect the required base class
for the properties value

icons.ai, check.png, check.svg
* Added "check" icon, used in menu right now to show which item
is selected

ve.ui.Icons-raster.css, ve.ui.Icons-vector.css
* Added check icon
* Removed :before pseudo selectors from most of the icon classes (not need by button tool anymore, makes them more reusable now)

ve.ui.Tool.css
* Adjusted drop down tool styles so menu appears below, instead
of on top, of the label
* Adjusted paragraph font size to better match actual content
* Updated class names to still work with menu widget changes
(items are their own widgets now)
* Updated selectors as per changes in the structure of button tools

ve.ui.Widget.css
* Added styles for buttons and menu items
* Adjusted menu styles

ve.ui.*ButtonTool.js
* Added config options argument passthrough

ve.ui.ButtonTool.js
* Moved var statement to the top inside constructor
* Switched to using "a" tag to get cross-browser :active support
* Added icon to inside of button to make icon styles more reusable
* Removed disabled support (now provided by widget parent class)

ve.ui.FormatDropDownTool.js
* Updated options initialization to construct menu item objects
* Modified handling of items to account for changes in menu and
item classes
* Optimized onUpdateState method a bit, adding early exit to
inner loop

ve.ui.ButtonTool.js, ve.ui.DropdownTool.js, ve.ui.Context.js,
ve.ui.Frame, ve.ui.Tool.js, ve.ui.Widget.js
* Added chain ability to non-getter methods

ve.ui.DropdownTool.js
* Removed items argument to constructor
* Updated code as per changes in menu class
* Fixed inconsistent naming of event handler methods
* Removed item event handling (now handled by items directly)
* Made use of this.$$ to ensure tool works in other frames

ve.ui.Tool.js
* Made tools inherit from widget
* Moved trigger registry event handler to a method

ve.ui.Context.js
* Switched from using menu to contain toolbar to a simple wrapper

ve.ui.js
* Added get$$ method, a convenience function for binding jQuery
to a specific document context

ve.ui.*Widget.js
* Switched to using a config options object instead of individual arguments
* Added options
* Factored out flags and labels into their own classes
* Refactored value setting methods for inputs

ve.ui.MenuWidget.js, ve.ui.MenuItemWidget.js
* Broke items out into their own classes
* Redesigned API
* Updated code that uses these classes
* Added support for keyboard interaction
* Made items flash when selected (delaying the hiding of the menu for 200ms)

ve.ui.LinkTargetInputWidget.js, ve.ui.MWLinkTargetInputWidget
* Refactored annotation setting methods

Change-Id: I7769bd5a5b79f1ab36f258ef9f2be583ca503ce6
2013-02-26 12:29:08 -08:00

652 lines
14 KiB
JavaScript

/*!
* VisualEditor UserInterface MenuWidget class.
*
* @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
* @license The MIT License (MIT); see LICENSE.txt
*/
/**
* Create an ve.ui.MenuWidget object.
*
* @class
* @extends ve.ui.Widget
*
* @constructor
* @param {Object} [config] Config options
* @cfg {jQuery} [$overlay=this.$$( 'body' )] Container to render menu into
* @cfg {jQuery} [$input=this.$$( '<input>' )] Input to bind keyboard handlers to
*/
ve.ui.MenuWidget = function VeUiMenuWidget( config ) {
// Config intialization
config = config || {};
// Parent constructor
ve.ui.Widget.call( this, config );
// Properties
this.isolated = !config.$input;
this.$input = config.$input || this.$$( '<input>' );
this.$overlay = config.$overlay || this.$$( 'body' );
this.groups = {};
this.items = {};
this.sequence = [];
this.$groups = this.$$( '<ul>' ).addClass( 've-ui-menuWidget-groups' );
this.visible = false;
this.newItems = [];
this.$previousFocus = null;
this.persist = false;
// Events
this.$.on( {
'mouseup': ve.bind( this.onMouseUp, this ),
'mousedown': ve.bind( this.onMouseDown, this )
} );
this.$input.on( 'keydown', ve.bind( this.onKeyDown, this ) );
// Initialization
this.$.hide().addClass( 've-ui-menuWidget' ).append( this.$groups );
this.$overlay.append( this.$ );
if ( !config.$input ) {
this.$.append( this.$input );
}
};
/* Inheritance */
ve.inheritClass( ve.ui.MenuWidget, ve.ui.Widget );
/* Events */
/**
* @event select
* @param {ve.ui.MenuItemWidget} item Selected item
*/
/* Methods */
/**
* Handle mouse up events.
*
* @method
* @private
* @param {jQuery.Event} e Mouse up event
*/
ve.ui.MenuWidget.prototype.onMouseUp = function () {
return false;
};
/**
* Handle mouse down events.
*
* @method
* @private
* @param {jQuery.Event} e Mouse down event
*/
ve.ui.MenuWidget.prototype.onMouseDown = function () {
return false;
};
/**
* Handle item select events.
*
* @method
* @private
* @param {ve.ui.MenuItemWidget} item Selected item
*/
ve.ui.MenuWidget.prototype.onItemSelect = function ( item ) {
var hash;
// Make selection mutually exclusive
for ( hash in this.items ) {
if ( this.items[hash] !== item ) {
this.items[hash].setSelected( false );
}
}
if ( !this.persist && !this.disabled ) {
this.emit( 'select', item );
this.disabled = true;
item.flash( ve.bind( function () {
this.hide();
this.disabled = false;
}, this ) );
}
};
/**
* Handle item highlight events.
*
* @method
* @private
* @param {ve.ui.MenuItemWidget} item Selected item
*/
ve.ui.MenuWidget.prototype.onItemHighlight = function ( item ) {
var hash;
// Make selection mutually exclusive
for ( hash in this.items ) {
if ( this.items[hash] !== item ) {
this.items[hash].setHighlighted( false );
}
}
};
/**
* Handles key down events.
*
* @method
* @param {jQuery.Event} e Key down event
*/
ve.ui.MenuWidget.prototype.onKeyDown = function ( e ) {
var handled = false,
highlightItem = this.getHighlightedItem();
if ( !this.disabled && this.visible ) {
switch ( e.keyCode ) {
// Enter
case 13:
this.selectItem( highlightItem );
handled = true;
break;
// Up arrow
case 38:
this.highlightItem( this.getRelativeItem( -1 ) );
handled = true;
break;
// Down arrow
case 40:
this.highlightItem( this.getRelativeItem( 1 ) );
handled = true;
break;
// Escape
case 27:
if ( highlightItem ) {
highlightItem.setHighlighted( false );
}
this.hide();
handled = true;
break;
}
if ( handled ) {
e.preventDefault();
}
}
};
/**
* Check if the menu is visible.
*
* @method
* @returns {boolean} Menu is visible
*/
ve.ui.MenuWidget.prototype.isVisible = function () {
return this.visible;
};
/**
* Get group names.
*
* @method
* @returns {string[]} Symbolic names of groups
*/
ve.ui.MenuWidget.prototype.getGroups = function () {
return ve.getObjectKeys( this.groups );
};
/**
* Get items.
*
* @method
* @returns {ve.ui.MenuItemWidget[]} Items
*/
ve.ui.MenuWidget.prototype.getItems = function () {
return ve.getObjectValues( this.items );
};
/**
* Get highlighted item.
*
* @method
* @returns {ve.ui.MenuItemWidget|null} Highlighted item
*/
ve.ui.MenuWidget.prototype.getHighlightedItem = function () {
var hash;
for ( hash in this.items ) {
if ( this.items[hash].isHighlighted() ) {
return this.items[hash];
}
}
return null;
};
/**
* Get selected item.
*
* @method
* @returns {ve.ui.MenuItemWidget|null} Selected item
*/
ve.ui.MenuWidget.prototype.getSelectedItem = function () {
var hash;
for ( hash in this.items ) {
if ( this.items[hash].isSelected() ) {
return this.items[hash];
}
}
return null;
};
/**
* Get an item from its data.
*
* This performs a hash comparison, not an identity comparison.
*
* @method
* @returns {ve.ui.MenuItemWidget|null} Item, or null if no match was found
*/
ve.ui.MenuWidget.prototype.getItemFromData = function ( data ) {
var hash = ve.getHash( data );
if ( hash in this.items ) {
return this.items[hash];
}
return null;
};
/**
* Get an item relative to the highlighted one.
*
* @method
* @param {number} direction Direction to move selection in
* @returns {ve.ui.MenuItemWidget|null} Item, or null if there are no items in the menu
*/
ve.ui.MenuWidget.prototype.getRelativeItem = function ( direction ) {
var item,
highlightedItem = this.getHighlightedItem(),
highlightedItemIndex = this.sequence.indexOf( highlightedItem ),
i = direction > 0 ?
// Default to 0 instead of -1, if nothing is selected let's start at the beginning
Math.max( 0, highlightedItemIndex + direction ) :
// Default to n-1 instead of -1, if nothing is selected let's start at the end
Math.min( highlightedItemIndex + direction, this.sequence.length - 1 ),
len = this.sequence.length,
inc = direction > 0 ? 1 : -1,
stopAt = i;
// Iterate to the next item in the sequence
while ( i < len ) {
item = this.sequence[i];
if ( item instanceof ve.ui.MenuItemWidget ) {
return item;
}
// Wrap around
i = ( i + inc + len ) % len;
if ( i === stopAt ) {
// We've looped around, I guess we're all alone
return item;
}
}
return null;
};
/**
* Selects the next item in the menu.
*
* @method
* @param {number} index Item index
* @returns {ve.ui.MenuItemWidget|null} Item, or null if there's not an item at the `index`
*/
ve.ui.MenuWidget.prototype.getItemFromIndex = function ( index ) {
var item,
i = 0,
len = this.sequence.length,
at = 0;
while ( i < len ) {
item = this.sequence[i];
if ( item instanceof ve.ui.MenuItemWidget ) {
if ( at === index ) {
return item;
}
at++;
}
i++;
}
return null;
};
/**
* Highlight an item.
*
* Highlighting is mutually exclusive.
*
* @method
* @param {ve.ui.MenuItemWidget} [item] Item to highlight, omit to deselect all
* @chainable
*/
ve.ui.MenuWidget.prototype.highlightItem = function ( item ) {
// Get item by value
item = this.getItemFromData( item && item.getData() );
// Update items
if ( item ) {
item.setHighlighted( true );
}
return this;
};
/**
* Select an item.
*
* @method
* @param {ve.ui.MenuItemWidget} [item] Item to select, omit to deselect all
* @chainable
*/
ve.ui.MenuWidget.prototype.selectItem = function ( item, persist ) {
var hash;
// Get item by value
item = this.getItemFromData( item && item.getData() );
this.persist = !!persist;
if ( item ) {
item.setSelected( true );
} else {
// Deselect all
for ( hash in this.items ) {
this.items[hash].setSelected( false );
}
}
this.persist = false;
return this;
};
/**
* Set which item is selected.
*
* This is different than selecting an item, because it doesn't send out select events. This is only
* to be used when updating the UI to reflect an already existing state, usually before showing it.
*
* @method
* @param {ve.ui.MenuItemWidget} [item] Item to select, omit to deselect all
* @chainable
*/
ve.ui.MenuWidget.prototype.setSelectedItem = function ( item ) {
var hash;
// Get item by value
item = this.getItemFromData( item && item.getData() );
if ( item ) {
item.setSelected( true, true );
} else {
// Deselect all
for ( hash in this.items ) {
this.items[hash].setSelected( false, true );
}
}
return this;
};
/**
* Show the menu.
*
* @method
* @chainable
*/
ve.ui.MenuWidget.prototype.show = function () {
var i, len, item;
if ( this.sequence.length ) {
this.$.show();
this.visible = true;
// Change focus to enable keyboard navigation
if ( this.isolated && !this.$input.is( ':focus' ) ) {
this.$previousFocus = this.$$( ':focus' );
this.$input.focus();
}
// When using jQuery.autoEllipsis, new items may have been deferred until visible
// TODO: Eliminate dependency on autoEllipsis and add this functionality to
// ve.ui.LabeledWidget, making it compatible with HTML contents, not just plain strings
if ( this.newItems.length ) {
for ( i = 0, len = this.newItems.length; i < len; i++ ) {
item = this.newItems[i];
if ( item.$.autoEllipsis ) {
item.$.autoEllipsis( { 'hasSpan': true, 'tooltip': true } );
}
}
this.newItems = [];
}
}
return this;
};
/**
* Hide the menu.
*
* @method
* @chainable
*/
ve.ui.MenuWidget.prototype.hide = function () {
this.$.hide();
this.visible = false;
if ( this.isolated && this.$previousFocus ) {
this.$previousFocus.focus();
this.$previousFocus = null;
}
return this;
};
/**
* Add groups.
*
* @method
* @param {Object.<string, string>} groups List of group labels, keyed by symbolic name
* @chainable
* @throws {Error} If a group being added already exists
*/
ve.ui.MenuWidget.prototype.addGroups = function ( groups ) {
var i, len, name, label, group, names;
if ( ve.isArray( groups ) ) {
names = {};
for ( i = 0, len = groups.length; i < len; i++ ) {
names[groups[i]] = '';
}
groups = names;
}
for ( name in groups ) {
if ( name in this.groups ) {
throw new Error( 'Name must be unique for each group' );
}
label = groups[name];
group = {
'name': name,
'items': [],
'$': this.$$( '<li>' ),
'$items': this.$$( '<ul>' )
};
if ( label ) {
group.$label = this.$$( '<span>' )
.text( label )
.addClass( 've-ui-menuWidget-group-label' );
group.$.append( group.$label );
}
group.$items.addClass( 've-ui-menuWidget-items' );
group.$
.hide()
.attr( 'rel', name )
.addClass( 've-ui-menuWidget-group' )
.append( group.$items );
this.$groups.append( group.$ );
this.groups[name] = group;
// Group names and item objects are collected in a sequence for consistent ordering
this.sequence.push( name );
}
return this;
};
/**
* Add items.
*
* Adding an existing item (by value) will move it.
*
* @method
* @param {ve.ui.MenuItemWidget[]} items Item
* @chainable
*/
ve.ui.MenuWidget.prototype.addItems = function ( items ) {
var i, len, hash, item, group, groupName;
for ( i = 0, len = items.length; i < len; i++ ) {
item = items[i];
hash = ve.getHash( item.getData() );
groupName = item.getGroup();
// Automatically add an unlabled group if group is missing
if ( !( groupName in this.groups ) ) {
this.addGroups( [groupName] );
}
// Check if item exists then remove it first, effectively "moving" it
if ( hash in this.items ) {
this.removeItems( [this.items[hash]] );
}
// Add the item
group = this.groups[groupName];
group.$.show();
group.$items.append( item.$ );
group.items.push( item );
this.items[hash] = item;
item.on( 'select', ve.bind( this.onItemSelect, this, item ) );
item.on( 'highlight', ve.bind( this.onItemHighlight, this, item ) );
// Items are inserted before their group name, for quick splicing
this.sequence.splice( this.sequence.indexOf( groupName ), 0, item );
// To use jQuery.autoEllipsis, items must be visible - keep a list of items to process later
// TODO: See #show, this functionality should be moved into ve.ui.LabeledWidget
if ( item.$.autoEllipsis ) {
if ( this.visible ) {
item.$.autoEllipsis( { 'hasSpan': true, 'tooltip': true } );
} else {
this.newItems.push( item );
}
}
}
return this;
};
/**
* Remove items.
*
* Items will be detached, not removed, so they can be used later.
*
* @method
* @param {ve.ui.MenuItemWidget[]} items Items to remove
* @chainable
*/
ve.ui.MenuWidget.prototype.removeItems = function ( items ) {
var i, len, hash, item, group;
// Remove specific items
for ( i = 0, len = items.length; i < len; i++ ) {
hash = ve.getHash( items[i] );
item = this.items[hash];
if ( item ) {
delete this.items[hash];
this.sequence.splice( this.sequence.indexOf( item ), 1 );
item.$.detach();
item.removeAllListeners( 'select' );
group = this.groups[item.getGroup()];
if ( group ) {
group.items.splice( group.items.indexOf( item ), 1 );
if ( !group.items.length ) {
group.$.hide();
}
}
}
}
return this;
};
/**
* Clear all items.
*
* Items will be detached, not removed, so they can be used later.
*
* @method
* @chainable
*/
ve.ui.MenuWidget.prototype.clearItems = function () {
var i, len, name;
// Remove all items, leaving empty groups
this.items = {};
this.$groups.children().hide().children().detach();
for ( name in this.groups ) {
this.groups[name].items = [];
}
for ( i = 0, len = this.sequence.length; i < len; i++ ) {
if ( this.sequence[i] instanceof ve.ui.MenuItemWidget ) {
this.sequence.splice( i, 0 );
}
}
return this;
};
/**
* Remove groups.
*
* Items within groups will also be removed.
*
* @method
* @param {string[]} groups Names of groups to remove
* @chainable
*/
ve.ui.MenuWidget.prototype.removeGroups = function ( groups ) {
var i, len, group;
for ( i = 0, len = groups.length; i < len; i++ ) {
group = this.groups[groups[i]];
if ( group ) {
delete this.groups[groups[i]];
this.sequence.splice( this.sequence.indexOf( group ), 1 );
this.removeItems( group.items );
group.$.remove();
}
}
return this;
};
/**
* Remove groups.
*
* Items within groups will also be removed.
*
* @method
* @chainable
*/
ve.ui.MenuWidget.prototype.clearGroups = function () {
// Remove all items
this.clearItems();
// Remove all groups
this.groups = {};
this.sequence = [];
this.$groups
.children()
// Detach the items in each group (they may be re-used)
.children()
.detach()
.end()
// Remove the group elements
.remove();
return this;
};