/**
* MenuSelectWidget is a {@link OO.ui.SelectWidget select widget} that contains options and
* is used together with OO.ui.MenuOptionWidget. It is designed be used as part of another widget.
* See {@link OO.ui.DropdownWidget DropdownWidget}, {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget},
* and {@link OO.ui.mixin.LookupElement LookupElement} for examples of widgets that contain menus.
* MenuSelectWidgets themselves are not instantiated directly, rather subclassed
* and customized to be opened, closed, and displayed as needed.
*
* By default, menus are clipped to the visible viewport and are not visible when a user presses the
* mouse outside the menu.
*
* Menus also have support for keyboard interaction:
*
* - Enter/Return key: choose and select a menu option
* - Up-arrow key: highlight the previous menu option
* - Down-arrow key: highlight the next menu option
* - Esc key: hide the menu
*
* Unlike most widgets, MenuSelectWidget is initially hidden and must be shown by calling #toggle.
*
* Please see the [OOjs UI documentation on MediaWiki][1] for more information.
* [1]: https://www.mediawiki.org/wiki/OOjs_UI/Widgets/Selects_and_Options
*
* @class
* @extends OO.ui.SelectWidget
* @mixes OO.ui.mixin.ClippableElement
* @mixes OO.ui.mixin.FloatableElement
*
* @constructor
* @param {Object} [config] Configuration options
* @param {OO.ui.TextInputWidget} [config.input] Text input used to implement option highlighting for menu items that match
* the text the user types. This config is used by {@link OO.ui.ComboBoxInputWidget ComboBoxInputWidget}
* and {@link OO.ui.mixin.LookupElement LookupElement}
* @param {jQuery} [config.$input] Text input used to implement option highlighting for menu items that match
* the text the user types. This config is used by {@link OO.ui.CapsuleMultiselectWidget CapsuleMultiselectWidget}
* @param {OO.ui.Widget} [config.widget] Widget associated with the menu's active state. If the user clicks the mouse
* anywhere on the page outside of this widget, the menu is hidden. For example, if there is a button
* that toggles the menu's visibility on click, the menu will be hidden then re-shown when the user clicks
* that button, unless the button (or its parent widget) is passed in here.
* @param {boolean} [config.autoHide=true] Hide the menu when the mouse is pressed outside the menu.
* @param {jQuery} [config.$autoCloseIgnore] If these elements are clicked, don't auto-hide the menu.
* @param {boolean} [config.hideOnChoose=true] Hide the menu when the user chooses an option.
* @param {boolean} [config.filterFromInput=false] Filter the displayed options from the input
* @param {boolean} [config.highlightOnFilter] Highlight the first result when filtering
* @param {number} [config.width] Width of the menu
*/
OO.ui.MenuSelectWidget = function OoUiMenuSelectWidget( config ) {
// Configuration initialization
config = config || {};
// Parent constructor
OO.ui.MenuSelectWidget.parent.call( this, config );
// Mixin constructors
OO.ui.mixin.ClippableElement.call( this, $.extend( {}, config, { $clippable: this.$group } ) );
OO.ui.mixin.FloatableElement.call( this, config );
// Properties
this.autoHide = config.autoHide === undefined || !!config.autoHide;
this.hideOnChoose = config.hideOnChoose === undefined || !!config.hideOnChoose;
this.filterFromInput = !!config.filterFromInput;
this.$input = config.$input ? config.$input : config.input ? config.input.$input : null;
this.$widget = config.widget ? config.widget.$element : null;
this.$autoCloseIgnore = config.$autoCloseIgnore || $( [] );
this.onDocumentMouseDownHandler = this.onDocumentMouseDown.bind( this );
this.onInputEditHandler = OO.ui.debounce( this.updateItemVisibility.bind( this ), 100 );
this.highlightOnFilter = !!config.highlightOnFilter;
this.width = config.width;
// Initialization
this.$element.addClass( 'oo-ui-menuSelectWidget' );
if ( config.widget ) {
this.setFocusOwner( config.widget.$tabIndexed );
}
// Initially hidden - using #toggle may cause errors if subclasses override toggle with methods
// that reference properties not initialized at that time of parent class construction
// TODO: Find a better way to handle post-constructor setup
this.visible = false;
this.$element.addClass( 'oo-ui-element-hidden' );
};
/* Setup */
OO.inheritClass( OO.ui.MenuSelectWidget, OO.ui.SelectWidget );
OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.ClippableElement );
OO.mixinClass( OO.ui.MenuSelectWidget, OO.ui.mixin.FloatableElement );
/* Events */
/**
* @event ready
*
* The menu is ready: it is visible and has been positioned and clipped.
*/
/* Methods */
/**
* Handles document mouse down events.
*
* @protected
* @param {MouseEvent} e Mouse down event
*/
OO.ui.MenuSelectWidget.prototype.onDocumentMouseDown = function ( e ) {
if (
this.isVisible() &&
!OO.ui.contains(
this.$element.add( this.$widget ).add( this.$autoCloseIgnore ).get(),
e.target,
true
)
) {
this.toggle( false );
}
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
var currentItem = this.findHighlightedItem() || this.getSelectedItem();
if ( !this.isDisabled() && this.isVisible() ) {
switch ( e.keyCode ) {
case OO.ui.Keys.LEFT:
case OO.ui.Keys.RIGHT:
// Do nothing if a text field is associated, arrow keys will be handled natively
if ( !this.$input ) {
OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
}
break;
case OO.ui.Keys.ESCAPE:
case OO.ui.Keys.TAB:
if ( currentItem ) {
currentItem.setHighlighted( false );
}
this.toggle( false );
// Don't prevent tabbing away, prevent defocusing
if ( e.keyCode === OO.ui.Keys.ESCAPE ) {
e.preventDefault();
e.stopPropagation();
}
break;
default:
OO.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
return;
}
}
};
/**
* Update menu item visibility and clipping after input changes (if filterFromInput is enabled)
* or after items were added/removed (always).
*
* @protected
*/
OO.ui.MenuSelectWidget.prototype.updateItemVisibility = function () {
var i, item, visible, section, sectionEmpty, filter, exactFilter,
firstItemFound = false,
anyVisible = false,
len = this.items.length,
showAll = !this.isVisible(),
exactMatch = false;
if ( this.$input && this.filterFromInput ) {
filter = showAll ? null : this.getItemMatcher( this.$input.val() );
exactFilter = this.getItemMatcher( this.$input.val(), true );
// Hide non-matching options, and also hide section headers if all options
// in their section are hidden.
for ( i = 0; i < len; i++ ) {
item = this.items[ i ];
if ( item instanceof OO.ui.MenuSectionOptionWidget ) {
if ( section ) {
// If the previous section was empty, hide its header
section.toggle( showAll || !sectionEmpty );
}
section = item;
sectionEmpty = true;
} else if ( item instanceof OO.ui.OptionWidget ) {
visible = showAll || filter( item );
exactMatch = exactMatch || exactFilter( item );
anyVisible = anyVisible || visible;
sectionEmpty = sectionEmpty && !visible;
item.toggle( visible );
if ( this.highlightOnFilter && visible && !firstItemFound ) {
// Highlight the first item in the list
this.highlightItem( item );
firstItemFound = true;
}
}
}
// Process the final section
if ( section ) {
section.toggle( showAll || !sectionEmpty );
}
if ( anyVisible && this.items.length && !exactMatch ) {
this.scrollItemIntoView( this.items[ 0 ] );
}
this.$element.toggleClass( 'oo-ui-menuSelectWidget-invisible', !anyVisible );
}
// Reevaluate clipping
this.clip();
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.bindKeyDownListener = function () {
if ( this.$input ) {
this.$input.on( 'keydown', this.onKeyDownHandler );
} else {
OO.ui.MenuSelectWidget.parent.prototype.bindKeyDownListener.call( this );
}
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.unbindKeyDownListener = function () {
if ( this.$input ) {
this.$input.off( 'keydown', this.onKeyDownHandler );
} else {
OO.ui.MenuSelectWidget.parent.prototype.unbindKeyDownListener.call( this );
}
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.bindKeyPressListener = function () {
if ( this.$input ) {
if ( this.filterFromInput ) {
this.$input.on( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
this.updateItemVisibility();
}
} else {
OO.ui.MenuSelectWidget.parent.prototype.bindKeyPressListener.call( this );
}
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.unbindKeyPressListener = function () {
if ( this.$input ) {
if ( this.filterFromInput ) {
this.$input.off( 'keydown mouseup cut paste change input select', this.onInputEditHandler );
this.updateItemVisibility();
}
} else {
OO.ui.MenuSelectWidget.parent.prototype.unbindKeyPressListener.call( this );
}
};
/**
* Choose an item.
*
* When a user chooses an item, the menu is closed, unless the hideOnChoose config option is set to false.
*
* Note that ‘choose’ should never be modified programmatically. A user can choose an option with the keyboard
* or mouse and it becomes selected. To select an item programmatically, use the #selectItem method.
*
* @param {OO.ui.OptionWidget} item Item to choose
* @chainable
*/
OO.ui.MenuSelectWidget.prototype.chooseItem = function ( item ) {
OO.ui.MenuSelectWidget.parent.prototype.chooseItem.call( this, item );
if ( this.hideOnChoose ) {
this.toggle( false );
}
return this;
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.addItems = function ( items, index ) {
// Parent method
OO.ui.MenuSelectWidget.parent.prototype.addItems.call( this, items, index );
this.updateItemVisibility();
return this;
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.removeItems = function ( items ) {
// Parent method
OO.ui.MenuSelectWidget.parent.prototype.removeItems.call( this, items );
this.updateItemVisibility();
return this;
};
/**
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.clearItems = function () {
// Parent method
OO.ui.MenuSelectWidget.parent.prototype.clearItems.call( this );
this.updateItemVisibility();
return this;
};
/**
* Toggle visibility of the menu. The menu is initially hidden and must be shown by calling
* `.toggle( true )` after its #$element is attached to the DOM.
*
* Do not show the menu while it is not attached to the DOM. The calculations required to display
* it in the right place and with the right dimensions only work correctly while it is attached.
* Side-effects may include broken interface and exceptions being thrown. This wasn't always
* strictly enforced, so currently it only generates a warning in the browser console.
*
* @fires ready
* @inheritdoc
*/
OO.ui.MenuSelectWidget.prototype.toggle = function ( visible ) {
var change, belowHeight, aboveHeight;
visible = ( visible === undefined ? !this.visible : !!visible ) && !!this.items.length;
change = visible !== this.isVisible();
if ( visible && !this.warnedUnattached && !this.isElementAttached() ) {
OO.ui.warnDeprecation( 'MenuSelectWidget#toggle: Before calling this method, the menu must be attached to the DOM.' );
this.warnedUnattached = true;
}
if ( change ) {
if ( visible && ( this.width || this.$floatableContainer ) ) {
this.setIdealSize( this.width || this.$floatableContainer.width() );
}
if ( visible ) {
// Reset position before showing the popup again. It's possible we no longer need to flip
// (e.g. if the user scrolled).
this.setVerticalPosition( 'below' );
}
}
// Parent method
OO.ui.MenuSelectWidget.parent.prototype.toggle.call( this, visible );
if ( change ) {
if ( visible ) {
this.bindKeyDownListener();
this.bindKeyPressListener();
this.togglePositioning( !!this.$floatableContainer );
this.toggleClipping( true );
if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
// If opening the menu downwards causes it to be clipped, flip it to open upwards instead
belowHeight = this.$element.height();
this.setVerticalPosition( 'above' );
if ( this.isClippedVertically() || this.isFloatableOutOfView() ) {
// If opening upwards also causes it to be clipped, flip it to open in whichever direction
// we have more space
aboveHeight = this.$element.height();
if ( aboveHeight < belowHeight ) {
this.setVerticalPosition( 'below' );
}
}
}
// Note that we do not flip the menu's opening direction if the clipping changes
// later (e.g. after the user scrolls), that seems like it would be annoying
this.$focusOwner.attr( 'aria-expanded', 'true' );
if ( this.getSelectedItem() ) {
this.$focusOwner.attr( 'aria-activedescendant', this.getSelectedItem().getElementId() );
this.getSelectedItem().scrollElementIntoView( { duration: 0 } );
}
// Auto-hide
if ( this.autoHide ) {
this.getElementDocument().addEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
}
this.emit( 'ready' );
} else {
this.$focusOwner.removeAttr( 'aria-activedescendant' );
this.unbindKeyDownListener();
this.unbindKeyPressListener();
this.$focusOwner.attr( 'aria-expanded', 'false' );
this.getElementDocument().removeEventListener( 'mousedown', this.onDocumentMouseDownHandler, true );
this.togglePositioning( false );
this.toggleClipping( false );
}
}
return this;
};