Robmoen has uploaded a new change for review.

  https://gerrit.wikimedia.org/r/55853


Change subject: Base commit for meta dialog category suggestion widget
......................................................................

Base commit for meta dialog category suggestion widget

VisualEditor.i18n.php
Added placeholder text for category input
Updated Link inspector i18n messages

ve.ui.MetaDialog.js
Add ve.ui.CategoryWidget with dummy data to editorPanel

ve.ui.Dialog.css
Added padding to editorPanel

ve.ui.Widget.css
Added CategoryWidget styles

ve.ui.PendingInputWidget.js,
Moved pushPending and popPending methods into pending class

ve.ui.CategoryInputMenuWidget.js,
New class Category Input Menu

ve.ui.CategoryInputWidget.js,
Category input class
Adds css class for input styles
Sets placeholder text

ve.ui.MWCategoryInputWidget.js
Class managing the menu and mw api requests

ve.ui.CategoryGroupItemWidget.js,
Category group item base class

ve.ui.CategoryWidget.js
New class for creating the main category widget to manage group items and input

ve.ui.GroupWidget.
Added addItem method and change addItems to use it

ve.ui.MWLinkTargetInputWidget.js
Mixin ve.ui.PendingInputWidget and remove pending methods
Prevent querying on spaces
Update menu text to use i18n messages

ve.ui.MenuWidget.js
Remove some cruft where it uses both config.input and config.$input

Change-Id: I5eafaa484a1924a566d3a1ee1d869293089d0ecf
---
M VisualEditor.i18n.php
M VisualEditor.php
M demos/ve/index.php
M modules/ve/ui/dialogs/ve.ui.MetaDialog.js
M modules/ve/ui/styles/ve.ui.Dialog.css
M modules/ve/ui/styles/ve.ui.Icons-raster.css
M modules/ve/ui/styles/ve.ui.Icons-vector.css
M modules/ve/ui/styles/ve.ui.Widget.css
A modules/ve/ui/widgets/ve.ui.CategoryGroupItemWidget.js
A modules/ve/ui/widgets/ve.ui.CategoryInputMenuWidget.js
A modules/ve/ui/widgets/ve.ui.CategoryInputWidget.js
A modules/ve/ui/widgets/ve.ui.CategoryWidget.js
M modules/ve/ui/widgets/ve.ui.GroupWidget.js
A modules/ve/ui/widgets/ve.ui.MWCategoryInputWidget.js
M modules/ve/ui/widgets/ve.ui.MWLinkTargetInputWidget.js
M modules/ve/ui/widgets/ve.ui.MenuWidget.js
A modules/ve/ui/widgets/ve.ui.PendingInputWidget.js
17 files changed, 843 insertions(+), 50 deletions(-)


  git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/VisualEditor 
refs/changes/53/55853/1

diff --git a/VisualEditor.i18n.php b/VisualEditor.i18n.php
index 9fe6511..9733389 100644
--- a/VisualEditor.i18n.php
+++ b/VisualEditor.i18n.php
@@ -17,6 +17,7 @@
        'visualeditor-ca-editsource' => 'Edit source',
        'visualeditor-ca-ve-edit' => 'VisualEditor',
        'visualeditor-ca-ve-create' => 'VisualEditor',
+       'visualeditor-category-input-placeholder' => 'Category name',
        'visualeditor-dialog-meta-title' => 'Page settings',
        'visualeditor-dialog-content-title' => 'Content settings',
        'visualeditor-dialog-action-apply' => 'Apply changes',
@@ -38,9 +39,9 @@
        'visualeditor-window-title' => 'Inspect',
        'visualeditor-linkinspector-title' => 'Hyperlink',
        'visualeditor-linkinspector-label-pagetitle' => 'Page title',
-       'visualeditor-linkinspector-suggest-existing-page' => 'Existing page',
+       'visualeditor-linkinspector-suggest-matching-page' => 'Matching page',
        'visualeditor-linkinspector-suggest-new-page' => 'New page',
-       'visualeditor-linkinspector-suggest-external-link' => 'Web link',
+       'visualeditor-linkinspector-suggest-external-link' => 'External link',
        'visualeditor-formatdropdown-title' => 'Change format',
        'visualeditor-formatdropdown-format-paragraph' => 'Paragraph',
        'visualeditor-formatdropdown-format-heading1' => 'Heading 1',
diff --git a/VisualEditor.php b/VisualEditor.php
index cfb634f..854a5e1 100644
--- a/VisualEditor.php
+++ b/VisualEditor.php
@@ -337,9 +337,15 @@
                        've/ui/widgets/ve.ui.MenuItemWidget.js',
                        've/ui/widgets/ve.ui.MenuSectionItemWidget.js',
                        've/ui/widgets/ve.ui.MenuWidget.js',
+                       've/ui/widgets/ve.ui.PendingInputWidget.js',
                        've/ui/widgets/ve.ui.TextInputMenuWidget.js',
                        've/ui/widgets/ve.ui.LinkTargetInputWidget.js',
                        've/ui/widgets/ve.ui.MWLinkTargetInputWidget.js',
+                       've/ui/widgets/ve.ui.CategoryInputMenuWidget.js',
+                       've/ui/widgets/ve.ui.CategoryInputWidget.js',
+                       've/ui/widgets/ve.ui.MWCategoryInputWidget.js',
+                       've/ui/widgets/ve.ui.CategoryGroupItemWidget.js',
+                       've/ui/widgets/ve.ui.CategoryWidget.js',
 
                        've/ui/dialogs/ve.ui.ContentDialog.js',
                        've/ui/dialogs/ve.ui.MetaDialog.js',
@@ -399,7 +405,7 @@
                        'visualeditor-inspector-title',
                        'visualeditor-linkinspector-title',
                        'visualeditor-linkinspector-label-pagetitle',
-                       'visualeditor-linkinspector-suggest-existing-page',
+                       'visualeditor-linkinspector-suggest-matching-page',
                        'visualeditor-linkinspector-suggest-new-page',
                        'visualeditor-linkinspector-suggest-external-link',
                        'visualeditor-formatdropdown-title',
@@ -444,6 +450,7 @@
                        'visualeditor-dialog-content-title',
                        'visualeditor-dialog-action-apply',
                        'visualeditor-dialog-action-cancel',
+                       'visualeditor-category-input-placeholder',
                ),
        ),
        'ext.visualEditor.icons-raster' => $wgVisualEditorResourceTemplate + 
array(
diff --git a/demos/ve/index.php b/demos/ve/index.php
index 0712330..4c4c90b 100644
--- a/demos/ve/index.php
+++ b/demos/ve/index.php
@@ -221,9 +221,15 @@
                <script 
src="../../modules/ve/ui/widgets/ve.ui.MenuItemWidget.js"></script>
                <script 
src="../../modules/ve/ui/widgets/ve.ui.MenuSectionItemWidget.js"></script>
                <script 
src="../../modules/ve/ui/widgets/ve.ui.MenuWidget.js"></script>
+               <script 
src="../../modules/ve/ui/widgets/ve.ui.PendingInputWidget.js"></script>
                <script 
src="../../modules/ve/ui/widgets/ve.ui.TextInputMenuWidget.js"></script>
                <script 
src="../../modules/ve/ui/widgets/ve.ui.LinkTargetInputWidget.js"></script>
                <script 
src="../../modules/ve/ui/widgets/ve.ui.MWLinkTargetInputWidget.js"></script>
+               <script 
src="../../modules/ve/ui/widgets/ve.ui.CategoryInputMenuWidget.js"></script>
+               <script 
src="../../modules/ve/ui/widgets/ve.ui.CategoryInputWidget.js"></script>
+               <script 
src="../../modules/ve/ui/widgets/ve.ui.MWCategoryInputWidget.js"></script>
+               <script 
src="../../modules/ve/ui/widgets/ve.ui.CategoryGroupItemWidget.js"></script>
+               <script 
src="../../modules/ve/ui/widgets/ve.ui.CategoryWidget.js"></script>
                <script 
src="../../modules/ve/ui/dialogs/ve.ui.ContentDialog.js"></script>
                <script 
src="../../modules/ve/ui/dialogs/ve.ui.MetaDialog.js"></script>
                <script 
src="../../modules/ve/ui/tools/ve.ui.ButtonTool.js"></script>
diff --git a/modules/ve/ui/dialogs/ve.ui.MetaDialog.js 
b/modules/ve/ui/dialogs/ve.ui.MetaDialog.js
index 4c5dc8c..86b53d3 100644
--- a/modules/ve/ui/dialogs/ve.ui.MetaDialog.js
+++ b/modules/ve/ui/dialogs/ve.ui.MetaDialog.js
@@ -71,6 +71,18 @@
        this.editorPanel.$.addClass( 've-ui-metaDialog-editorPanel' );
        this.$body.append( this.layout.$ );
        this.outlinePanel.$.append( this.outlineWidget.$ );
+
+       // Dummmy categories data
+       var categoryWidget = new ve.ui.CategoryWidget( {
+                       '$$': this.$$,
+                       '$overlay': this.surface.$overlay,
+                       'categoryItems': [
+                               { 'name': 'Living people', 'value': 'Living 
people', 'metaItem': {} },
+                               { 'name': 'Cats', 'value': 'Cats', 'metaItem': 
{} }
+                       ]
+               } );
+
+       this.editorPanel.$.append( categoryWidget.$ );
        this.layout.update();
 };
 
diff --git a/modules/ve/ui/styles/ve.ui.Dialog.css 
b/modules/ve/ui/styles/ve.ui.Dialog.css
index 2d47911..59b0d2b 100644
--- a/modules/ve/ui/styles/ve.ui.Dialog.css
+++ b/modules/ve/ui/styles/ve.ui.Dialog.css
@@ -70,4 +70,9 @@
 
 .ve-ui-metaDialog-outlinePanel {
        border-right: solid 1px #ddd;
-}
\ No newline at end of file
+}
+
+.ve-ui-metaDialog-editorPanel {
+       padding: 1em;
+       box-sizing: border-box;
+}
diff --git a/modules/ve/ui/styles/ve.ui.Icons-raster.css 
b/modules/ve/ui/styles/ve.ui.Icons-raster.css
index 701246f..23c22d4 100644
--- a/modules/ve/ui/styles/ve.ui.Icons-raster.css
+++ b/modules/ve/ui/styles/ve.ui.Icons-raster.css
@@ -15,6 +15,10 @@
        background-image: url(images/icons/language.png);
 }
 
+.ve-ui-icon-callout {
+       background-image: url(images/callout.png);
+}
+
 .ve-ui-icon-categories-big {
        /* @embed */
        background-image: url(images/icons/categories-big.png);
diff --git a/modules/ve/ui/styles/ve.ui.Icons-vector.css 
b/modules/ve/ui/styles/ve.ui.Icons-vector.css
index efd55dd..0114caf 100644
--- a/modules/ve/ui/styles/ve.ui.Icons-vector.css
+++ b/modules/ve/ui/styles/ve.ui.Icons-vector.css
@@ -15,6 +15,10 @@
        background-image: url(images/icons/language.svg);
 }
 
+.ve-ui-icon-callout {
+       background-image: url(images/callout.svg);
+}
+
 .ve-ui-icon-categories-big {
        /* @embed */
        background-image: url(images/icons/categories-big.svg);
diff --git a/modules/ve/ui/styles/ve.ui.Widget.css 
b/modules/ve/ui/styles/ve.ui.Widget.css
index 137be49..9e4844c 100644
--- a/modules/ve/ui/styles/ve.ui.Widget.css
+++ b/modules/ve/ui/styles/ve.ui.Widget.css
@@ -273,9 +273,9 @@
        color: #888;
 }
 
-/* ve.ui.MWLinkTargetInputWidget */
+/* ve.ui.PendingInputWidget */
 
-.ve-ui-mwLinkTargetInputWidget-pending input {
+.ve-ui-pendingInputWidget input {
        background-image: url(images/pending.gif);
 }
 
@@ -300,3 +300,160 @@
 .ve-ui-mwLinkTargetInputWidget-menu .ve-ui-menuItemWidget[rel=externalLink] {
        color: #0645AD;
 }
+
+/* ve.ui.CategoryListWidget */
+
+.ve-ui-categoryListWidget {
+       border: 1px solid #CCC;
+       border-radius: .25em;
+       padding: .75em;
+}
+
+.ve-ui-categoryInputWidget {
+       float: left;
+}
+
+/* ve.ui.CategoryListItemWidget */
+
+.ve-ui-categoryListItemWidget {
+       position: relative;
+       float: left;
+       margin: .25em;
+}
+
+.ve-ui-categoryListItemButton {
+       position: relative;
+       padding: .5em .75em .5em 1.125em;
+       vertical-align: top;
+       cursor: pointer;
+       -webkit-touch-callout: none;
+       -webkit-user-select: none;
+       -moz-user-select: none;
+       -ms-user-select: none;
+       user-select: none;
+       opacity: 0.8;
+       /* Animation */
+       -webkit-transition: border-color 100ms;
+       -moz-transition: border-color 100ms;
+       -ms-transition: border-color 100ms;
+       -o-transition: border-color 100ms;
+       transition: border-color 100ms;
+
+       /* Gray */
+       border-radius: 1.25em;
+       border: 1px #c9c9c9 solid;
+       background-color: #ffffff;
+       filter: progid:DXImageTransform.Microsoft.gradient(
+               GradientType=0,startColorstr=#ffffff, endColorstr=#f0f0f0
+       );
+       background-image: -webkit-gradient(
+               linear, right top, right bottom, color-stop(0%,#ffffff), 
color-stop(100%,#f0f0f0)
+       );
+       background-image: -webkit-linear-gradient(top, #ffffff 0%, #f0f0f0 
100%);
+       background-image: -moz-linear-gradient(top, #ffffff 0%, #f0f0f0 100%);
+       background-image: -ms-linear-gradient(top, #ffffff 0%, #f0f0f0 100%);
+       background-image: -o-linear-gradient(top, #ffffff 0%, #f0f0f0 100%);
+       background-image: linear-gradient(top, #ffffff 0%, #f0f0f0 100%);
+}
+
+.ve-ui-categoryListItemButton:hover {
+       opacity: 1;
+}
+
+.ve-ui-categoryListItemButton span {
+       float: left;
+       color: #333;
+}
+
+.ve-ui-categoryListItemButton:active {
+       border-color: #ddd;
+       box-shadow: inset 0 1px 4px 0 rgba(0, 0, 0, 0.07);
+       color: black;
+}
+
+.ve-ui-categoryListItemControl {
+       margin: .125em 0 0 .5em;
+       float: left;
+       width: 12px;
+       height: 12px;
+}
+
+.ve-ui-categoryInputMenuWidget {
+       z-index:201;
+}
+
+/* ve.ui.CategoryInputWidget */
+
+.ve-ui-categoryInputWidget {
+       float: left;
+}
+
+.ve-ui-categoryInputWidget input {
+       display: inline-block;
+       font-size: 1em;
+       font-family: sans-serif;
+       background: none;
+       /* HACK: to match the categoryListItem height */
+       border: 1px solid #fff;
+       outline: none;
+       padding: 0.5em;
+}
+
+.ve-ui-categoryListItemMenu {
+       position: absolute;
+       right: 1.75em;
+       top: 1.5em;
+}
+
+.ve-ui-categoryListItemMenuCallout {
+       position: absolute;
+       right: -1em;
+       top: 0;
+       background-image: url(images/callout.svg);
+       background-repeat: no-repeat;
+       width: 15px;
+       height: 8px;
+       z-index:300;
+}
+
+.ve-ui-categoryListItemMenuBody {
+       position: absolute;
+       margin-top: 7px;
+       right: -1.75em;
+       border-radius: .25em;
+       border: solid 1px #ccc;
+       border-radius: 0.25em;
+       background-color: #fff;
+       z-index: 299;
+}
+
+.ve-ui-categoryListItemMenuBody .ve-ui-iconButtonWidget {
+       display:block;
+       float: left;
+}
+
+.ve-ui-categoryListItemMenuBody label {
+       display: block;
+       margin-left: .125em;
+       line-height: 2em;
+       height: 2em;
+       cursor: pointer;
+       opacity: .8;
+}
+
+/* Give padding to all body divs 1 level deep */
+.ve-ui-categoryListItemMenuBody > div {
+       padding: 0.5em;
+}
+
+.ve-ui-categoryListItemMenuBody .ve-ui-removeButtonLabel:hover {
+       opacity: 1;
+}
+
+/* CategoryListItem SortKey Control */
+
+.ve-ui-categoryListItemSortKeyForm{
+       border-top: 1px solid #ccc;
+       padding: .5em .75em 0 .75em;
+       display: block;
+}
diff --git a/modules/ve/ui/widgets/ve.ui.CategoryGroupItemWidget.js 
b/modules/ve/ui/widgets/ve.ui.CategoryGroupItemWidget.js
new file mode 100644
index 0000000..622f80d
--- /dev/null
+++ b/modules/ve/ui/widgets/ve.ui.CategoryGroupItemWidget.js
@@ -0,0 +1,130 @@
+/*!
+ * VisualEditor UserInterface CategoryGroupItemWidget class.
+ *
+ * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+/**
+ * Creates an ve.ui.CategoryGroupItemWidget object.
+ *
+ * @class
+ * @abstract
+ * @extends ve.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Config options
+ * @cfg {string} [name=''] HTML input name
+ * @cfg {string} [value=''] Input value
+ * @cfg {Object} [metaItem] Meta Item Reference
+ */
+ve.ui.CategoryGroupItemWidget = function VeUiCategoryGroupItemWidget( config ) 
{
+       // Config intialization
+       config = config || {};
+
+       // Parent constructor
+       ve.ui.Widget.call( this, config );
+
+       // Properties
+       this.name = config.item.value;
+       this.value = config.item.value;
+       this.sortKey = config.item.sortKey || '';
+       this.metaItem = config.metaItem;
+
+       this.menuOpen = false;
+       this.hideTimeout = null;
+
+       // Initialization
+
+       // Button
+       this.$label = this.$$( '<span>' ).text( this.value );
+       this.$arrow = this.$$( '<div>' ).addClass( 
've-ui-categoryListItemControl ve-ui-icon-down' );
+       this.$categoryItem = this.$$( '<div>' ).addClass( 
've-ui-categoryListItemButton' )
+               .append( this.$label, this.$arrow, this.$$( '<div>' ).css( 
'clear', 'both' ) );
+
+       // Menu
+       this.removeButton = new ve.ui.IconButtonWidget( {
+               '$$': this.$$, 'icon': 'remove', 'title': ve.msg( 
'visualeditor-inspector-remove-tooltip' )
+       } );
+       this.$removeButtonLabel = this.$$( '<label>' )
+               .text( ve.msg( 'visualeditor-inspector-remove-tooltip' ) );
+
+       this.$removeControl = this.$$( '<div>' ).addClass( 
've-ui-categoryListItemRemoveControl' )
+               .append( this.removeButton.$, this.$removeButtonLabel );
+
+       this.sortKeyInput = new ve.ui.TextInputWidget( { '$$': this.$$ } );
+       this.sortKeyLabel = new ve.ui.InputLabelWidget(
+               { '$$': this.$$, 'input': this.sortKeyInput.$, 'label': 'Page 
name in category' }
+       );
+
+       this.$sortKeyForm = this.$$( '<form>' ).addClass( 
've-ui-categoryListItemSortKeyForm' )
+               .append( this.sortKeyLabel.$, this.sortKeyInput.$ );
+
+       this.$menu = this.$$( '<div>' ).addClass( 've-ui-categoryListItemMenu' )
+               .append(
+                       this.$$( '<div>' ).addClass( 
've-ui-categoryListItemMenuCallout' ),
+                       this.$$( '<div>' ).addClass( 
've-ui-categoryListItemMenuBody' )
+                               .append( this.$removeControl, this.$sortKeyForm 
)
+               ).hide();
+
+       // Assemble the widget elements
+       this.$.addClass( 've-ui-categoryListItemWidget' ).append( 
this.$categoryItem, this.$menu );
+
+       // Events
+       this.$categoryItem.on( 'click', ve.bind( this.onClick, this ) );
+       this.$.on( {
+               'mouseleave': ve.bind( this.hideMenu, this ),
+               'mouseenter': ve.bind( this.onMouseEnter, this )
+       } );
+       this.removeButton.on( 'click', ve.bind( this.onRemove, this ) );
+       this.$removeButtonLabel.on( 'click', ve.bind( this.onRemove, this ) );
+       this.$sortKeyForm.on( 'submit', ve.bind( this.onSortKeySubmit, this ) );
+};
+
+/* Inheritance */
+
+ve.inheritClass( ve.ui.CategoryGroupItemWidget, ve.ui.Widget );
+
+
+/* Methods */
+
+ve.ui.CategoryGroupItemWidget.prototype.onClick = function () {
+       if ( this.menuOpen ) {
+               this.hideMenu();
+       } else {
+               this.showMenu();
+       }
+       return false;
+};
+
+ve.ui.CategoryGroupItemWidget.prototype.onRemove = function () {
+       this.emit( 'removeCategory', this );
+       this.$.remove();
+};
+
+ve.ui.CategoryGroupItemWidget.prototype.showMenu = function () {
+       clearTimeout( this.hideTimeout );
+       this.$menu.fadeIn( 'fast' );
+       this.menuOpen = true;
+};
+
+ve.ui.CategoryGroupItemWidget.prototype.hideMenu = function () {
+       this.hideTimeout = setTimeout( ve.bind( function() {
+               this.$menu.fadeOut( 'fast' );
+               this.menuOpen = false;
+       }, this ), 300 );
+};
+
+ve.ui.CategoryGroupItemWidget.prototype.onMouseEnter = function () {
+       clearTimeout( this.hideTimeout );
+};
+
+ve.ui.CategoryGroupItemWidget.prototype.onSortKeySubmit = function () {
+       var sortKeyInputValue = $.trim( this.sortKeyInput.$input.val() );
+       if( sortKeyInputValue !== '' ) {
+               this.sortKey = sortKeyInputValue;
+               this.emit( 'updateSortkey', this );
+               this.hideMenu();
+       }
+       return false;
+};
diff --git a/modules/ve/ui/widgets/ve.ui.CategoryInputMenuWidget.js 
b/modules/ve/ui/widgets/ve.ui.CategoryInputMenuWidget.js
new file mode 100644
index 0000000..b54bfb9
--- /dev/null
+++ b/modules/ve/ui/widgets/ve.ui.CategoryInputMenuWidget.js
@@ -0,0 +1,72 @@
+/*!
+ * VisualEditor UserInterface CategoryInputMenuWidget class.
+ *
+ * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+/**
+ * Creates an ve.ui.CategoryInputMenuWidget object.
+ *
+ * @class
+ * @extends ve.ui.MenuWidget
+ *
+ * @constructor
+ * @param {ve.ui.TextInputWidget} input Text input widget to provide menu for
+ * @param {Object} [config] Config options
+ */
+ve.ui.CategoryInputMenuWidget = function VeUiCategoryInputMenuWidget( input, 
config ) {
+       // Parent constructor
+       ve.ui.MenuWidget.call( this, config );
+
+       // Properties
+       this.input = input;
+
+       // Initialization
+       this.$.addClass( 've-ui-categoryInputMenuWidget' );
+
+       // Events
+       $( window ).on( 'resize', ve.bind( this.position, this ) );
+};
+
+/* Inheritance */
+
+ve.inheritClass( ve.ui.CategoryInputMenuWidget, ve.ui.MenuWidget );
+
+/**
+ * Shows the menu.
+ *
+ * @method
+ * @chainable
+ */
+ve.ui.CategoryInputMenuWidget.prototype.show = function () {
+       // Call parent method
+       ve.ui.MenuWidget.prototype.show.call( this );
+       this.position();
+       return this;
+};
+
+/**
+ * Positions the menu.
+ *
+ * @method
+ * @chainable
+ */
+ve.ui.CategoryInputMenuWidget.prototype.position = function () {
+       var dim, offset,
+               $categoryList = this.input.$.parent();
+
+       dim = $categoryList.offset();
+       // Add height and width and adjust for 1px border.
+       dim.top += $categoryList.outerHeight( true ) - 2;
+       dim.width = $categoryList.outerWidth( true ) - 2;
+
+       if ( this.input.$$.frame ) {
+               offset = this.input.$$.frame.$.offset();
+               dim.left += offset.left;
+               dim.top += offset.top;
+       }
+
+       this.$.css( dim );
+       return this;
+};
diff --git a/modules/ve/ui/widgets/ve.ui.CategoryInputWidget.js 
b/modules/ve/ui/widgets/ve.ui.CategoryInputWidget.js
new file mode 100644
index 0000000..02a4557
--- /dev/null
+++ b/modules/ve/ui/widgets/ve.ui.CategoryInputWidget.js
@@ -0,0 +1,34 @@
+/*!
+ * VisualEditor UserInterface CategoryInputWidget class.
+ *
+ * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+/**
+ * Creates an ve.ui.CategoryInputWidget object.
+ *
+ * @class
+ * @extends ve.ui.InputWidget
+ *
+ * @constructor
+ * @param {Object} [config] Config options
+ */
+ve.ui.CategoryInputWidget = function VeUiCategoryInputWidget( config ) {
+       // Parent constructor
+       ve.ui.InputWidget.call( this, config );
+
+       // Initialization
+       this.$.addClass( 've-ui-categoryInputWidget' );
+
+       // Set placholder text
+       this.$input.attr( 'placeholder', ve.msg( 
'visualeditor-category-input-placeholder' ) );
+};
+
+/* Inheritance */
+
+ve.inheritClass( ve.ui.CategoryInputWidget, ve.ui.InputWidget );
+
+/* Static Properties */
+
+ve.ui.CategoryInputWidget.static.inputType = 'text';
diff --git a/modules/ve/ui/widgets/ve.ui.CategoryWidget.js 
b/modules/ve/ui/widgets/ve.ui.CategoryWidget.js
new file mode 100644
index 0000000..bdf5a5d
--- /dev/null
+++ b/modules/ve/ui/widgets/ve.ui.CategoryWidget.js
@@ -0,0 +1,83 @@
+/*!
+ * VisualEditor UserInterface CategoryWidget class.
+ *
+ * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+/**
+ * Creates an ve.ui.CategoryWidget object.
+ *
+ * @class
+ * @abstract
+ * @extends ve.ui.Widget
+ *
+ * @constructor
+ * @param {Object} [config] Config options
+ * @cfg {Object[]} [categoryItems] MW category metaItem
+ */
+ve.ui.CategoryWidget = function VeUiCategoryWidget( config ) {
+       // Config intialization
+       config = config || {};
+
+       // Parent constructor
+       ve.ui.Widget.call( this, config );
+       // Initialization
+       this.$.addClass( 've-ui-categoryListWidget' );
+
+       // Properties
+       this.categoryInput = new ve.ui.MWCategoryInputWidget( {
+               '$$': this.$$, '$overlay': config.$overlay
+       } );
+
+       this.categoryGroup = new ve.ui.GroupWidget( { '$$': this.$$ } );
+       // Init
+       this.$.append( this.categoryGroup.$, this.categoryInput.$ , this.$$( 
'<div>' ).css( 'clear', 'both' ) );
+       if ( 'categoryItems' in config ) {
+               this.addItems( config.categoryItems );
+       }
+
+       // Events
+       this.categoryInput.addListenerMethods( this, { 'newCategory': 
'onNewCategory' } );
+};
+
+/* Inheritance */
+
+ve.inheritClass( ve.ui.CategoryWidget, ve.ui.Widget );
+
+
+/* Methods */
+
+ve.ui.CategoryWidget.prototype.addItems = function ( items ) {
+       var i, len;
+       for ( i = 0, len = items.length; i < len; i++ ) {
+               this.addItem( items[i] );
+       }
+       return this;
+};
+
+ve.ui.CategoryWidget.prototype.addItem = function ( item ) {
+       var categoryGroupItem = new ve.ui.CategoryGroupItemWidget( { '$$': 
this.$$, 'item': item } );
+               // Bind category item events.
+               categoryGroupItem.addListenerMethods( this, {
+                       'removeCategory': 'onRemoveCategory',
+                       'updateSortkey': 'onUpdateSortkey'
+               } );
+               // Add to items.
+               this.categoryGroup.addItem( categoryGroupItem );
+       return this;
+};
+
+ve.ui.CategoryWidget.prototype.onNewCategory = function ( item ) {
+       // New category
+       this.addItem( item );
+};
+
+ve.ui.CategoryWidget.prototype.onRemoveCategory = function ( item ) {
+       this.categoryGroup.removeItems( [item] );
+};
+
+ve.ui.CategoryWidget.prototype.onUpdateSortkey = function ( item ) {
+       // TODO: Mutate metaItem sortKey with item.sortKey
+};
+
diff --git a/modules/ve/ui/widgets/ve.ui.GroupWidget.js 
b/modules/ve/ui/widgets/ve.ui.GroupWidget.js
index ed1b92a..9d9a22b 100644
--- a/modules/ve/ui/widgets/ve.ui.GroupWidget.js
+++ b/modules/ve/ui/widgets/ve.ui.GroupWidget.js
@@ -62,20 +62,29 @@
  * @chainable
  */
 ve.ui.GroupWidget.prototype.addItems = function ( items ) {
-       var i, len, item;
-
+       var i, len;
        for ( i = 0, len = items.length; i < len; i++ ) {
-               item = items[i];
-
-               // Check if item exists then remove it first, effectively 
"moving" it
-               if ( this.items.indexOf( item ) !== -1 ) {
-                       this.removeItems( [item] );
-               }
-               // Add the item
-               this.items.push( item );
-               this.$.append( item.$ );
-               this.$items = this.$items.add( item.$ );
+               this.addItem( items[i] );
        }
+       return this;
+};
+
+/**
+ * Add item.
+ *
+ * @method
+ * @param {ve.ui.Widget[]} items Item
+ * @chainable
+ */
+ve.ui.GroupWidget.prototype.addItem = function ( item ) {
+       // Check if item exists then remove it first, effectively "moving" it
+       if ( this.items.indexOf( item ) !== -1 ) {
+               this.removeItems( [item] );
+       }
+       // Add the item
+       this.items.push( item );
+       this.$.append( item.$ );
+       this.$items = this.$items.add( item.$ );
 
        return this;
 };
@@ -91,7 +100,6 @@
  */
 ve.ui.GroupWidget.prototype.removeItems = function ( items ) {
        var i, len, item, index;
-
        // Remove specific items
        for ( i = 0, len = items.length; i < len; i++ ) {
                item = items[i];
diff --git a/modules/ve/ui/widgets/ve.ui.MWCategoryInputWidget.js 
b/modules/ve/ui/widgets/ve.ui.MWCategoryInputWidget.js
new file mode 100644
index 0000000..0362ceb
--- /dev/null
+++ b/modules/ve/ui/widgets/ve.ui.MWCategoryInputWidget.js
@@ -0,0 +1,259 @@
+ve.ui.MWCategoryInputWidget = function VeUiMWCategoryInputWidget( config ) {
+       // Config intialization
+       config = config || {};
+
+       // Parent constructor
+       ve.ui.CategoryInputWidget.call( this, config );
+
+       // Properties
+       this.category = null;
+       this.existingCategories = {};
+       this.matchingCategories = {};
+       this.matchingCategoriesQuery = null;
+       this.matchingCategoriesRequest = null;
+       this.existingCategoriesQuery = null;
+       this.existingCategoriesRequest = null;
+       this.categoryPrefix = 'Category:';
+       this.previousMatches = [];
+
+       this.$overlay = config.$overlay || this.$$( 'body' );
+       this.menu = new ve.ui.CategoryInputMenuWidget( this,
+               { '$$': ve.ui.get$$( this.$overlay ), 'input': this }
+       );
+
+       // Events
+       this.$input.on( {
+               'click': ve.bind( this.onClick, this ),
+               'focus': ve.bind( this.onFocus, this ),
+               'blur': ve.bind( this.onBlur, this )
+       } );
+
+       this.menu.on( 'select', ve.bind( this.onMenuItemSelect, this ) );
+       this.addListenerMethods( this, {'change': 'onChange'} );
+
+       // Initialization
+       this.$overlay.append( this.menu.$ );
+       this.$.addClass( 've-ui-mwCategoryInputWidget' );
+       this.menu.$.addClass( 've-ui-mwCategoryInputWidget-menu' );
+};
+
+/* Inheritance */
+
+ve.inheritClass( ve.ui.MWCategoryInputWidget, ve.ui.CategoryInputWidget );
+
+ve.mixinClass( ve.ui.MWCategoryInputWidget, ve.ui.PendingInputWidget );
+
+/* Methods */
+
+ve.ui.MWCategoryInputWidget.prototype.onChange = function () {
+       this.openMenu();
+};
+
+ve.ui.MWCategoryInputWidget.prototype.onClick = function () {
+       if ( !this.disabled ) {
+               this.openMenu();
+       }
+};
+
+ve.ui.MWCategoryInputWidget.prototype.onFocus = function () {
+       if ( !this.disabled ) {
+               this.openMenu();
+       }
+};
+
+ve.ui.MWCategoryInputWidget.prototype.onBlur = function () {
+       this.menu.hide();
+};
+
+ve.ui.MWCategoryInputWidget.prototype.onMenuItemSelect = function ( item ) {
+       if ( item ) {
+               this.emit( 'newCategory', item.getData() );
+               this.$input.val( '' );
+       }
+};
+
+ve.ui.MWCategoryInputWidget.prototype.openMenu = function () {
+       // Check for valid input prior to populating anything
+       if ( this.value.length && $.trim( this.value ) !== '' ) {
+               this.populateMenu();
+               this.queryCategoryExistence();
+               this.queryMatchingCategories();
+               // Show if not already visible
+               if ( !this.menu.isVisible() ) {
+                       this.menu.show();
+               }
+       } else {
+               this.menu.hide();
+       }
+       return this;
+};
+
+
+/**
+ * Populates the menu.
+ *
+ * @method
+ * @chainable
+ */
+ve.ui.MWCategoryInputWidget.prototype.populateMenu = function () {
+       var i, len,
+               items = [],
+               menu$$ = this.menu.$$,
+               category = this.getCategoryItemFromValue( this.value ),
+               categoryExists = this.existingCategories[this.value],
+               matchingCategories = this.matchingCategories[this.value];
+
+       // Reset
+       this.menu.clearItems();
+
+       // Is new category?
+       if ( !categoryExists && ( !matchingCategories || 
matchingCategories.indexOf( this.value ) === -1 ) ) {
+               items.push( new ve.ui.MenuSectionItemWidget(
+                       'newCategory', { '$$': menu$$, 'label': 'New category' }
+               ) );
+               items.push( new ve.ui.MenuItemWidget( category,
+                       { '$$': menu$$, 'rel': 'newCategory', 'label': 
this.value }
+               ) );
+       }
+
+       // Matching
+       if ( matchingCategories && matchingCategories.length ) {
+               items.push( new ve.ui.MenuSectionItemWidget(
+                       'matchingCategories', { '$$': menu$$, 'label': 
'Matching categories' }
+               ) );
+               for ( i = 0, len = matchingCategories.length; i < len; i++ ) {
+                       items.push( new ve.ui.MenuItemWidget(
+                               this.getCategoryItemFromValue( 
matchingCategories[i] ),
+                               { '$$': menu$$, 'rel': 'matchingCategories', 
'label': matchingCategories[i] }
+                       ) );
+               }
+               this.previousMatches = matchingCategories;
+       }
+
+       // Add items
+       this.menu.addItems( items );
+
+       // Auto-select
+       if ( !this.menu.getSelectedItem() ) {
+               this.menu.selectItem( this.menu.getClosestSelectableItem( 0 ), 
true );
+       }
+       this.menu.highlightItem( this.menu.getSelectedItem() );
+
+       return this;
+};
+
+ve.ui.MWCategoryInputWidget.prototype.getCategoryItemFromValue = function ( 
value ) {
+       return { 'name': value, 'value': value, 'metaItem': {} };
+};
+
+/**
+ * Checks page existence for the current value.
+ *
+ * {ve.ui.MWLinkTargetInputWidget.populateMenu} will be called immediately if 
the page existence has
+ * been cached, or as soon as the API returns a result.
+ *
+ * @method
+ * @chainable
+ */
+ve.ui.MWCategoryInputWidget.prototype.queryCategoryExistence = function () {
+       if ( this.existingCategoriesQuery === this.value ) {
+               // Ignore duplicate requests
+               return;
+       }
+
+       if ( this.existingCategoriesRequest ) {
+               this.existingCategoriesRequest.abort();
+               this.existingCategoriesRequest = null;
+       }
+       if ( this.value in this.existingCategories ) {
+               this.populateMenu();
+       } else {
+               this.pushPending();
+               this.existingCategoriesRequest = $.ajax( {
+                       'url': mw.util.wikiScript( 'api' ),
+                       'data': {
+                               'format': 'json',
+                               'action': 'query',
+                               'indexpageids': '',
+                               'titles': this.categoryPrefix + this.value,
+                               'converttitles': ''
+                       },
+                       'dataType': 'json',
+                       'success': ve.bind( function ( data ) {
+                               this.existingCategoriesRequest = null;
+                               var page,
+                                       exists = false;
+                               if ( data.query ) {
+                                       page = 
data.query.pages[data.query.pageids[0]];
+                                       exists = ( page.missing === undefined 
&& page.invalid === undefined );
+                                       // Cache result for normalized title
+                                       this.existingCategories[page.title] = 
exists;
+                               }
+                               // Cache result for original input
+                               this.existingCategories[this.value] = exists;
+                               this.populateMenu();
+                       }, this ),
+                       'complete': ve.bind( function () {
+                               this.popPending();
+                       }, this )
+               } );
+       }
+       return this;
+};
+
+/**
+ * Checks matching pages for the current value.
+ *
+ * {ve.ui.MWCategoryInputWidget.populateMenu} will be called immediately if 
matching categories have
+ * been cached, or as soon as the API returns a result.
+ *
+ * @method
+ * @chainable
+ */
+ve.ui.MWCategoryInputWidget.prototype.queryMatchingCategories = function () {
+       if ( this.matchingCategoriesQuery === this.value ) {
+               // Ignore duplicate requests
+               return;
+       }
+       if ( this.matchingCategoriesRequest ) {
+               this.matchingCategoriesRequest.abort();
+               this.matchingCategoriesQuery = null;
+               this.matchingCategoriesRequest = null;
+       }
+       if ( this.value in this.matchingCategories ) {
+               this.populateMenu();
+       } else {
+               this.pushPending();
+               this.matchingCategoriesRequest = $.ajax( {
+                       'url': mw.util.wikiScript( 'api' ),
+                       'data': {
+                               'format': 'json',
+                               'action': 'opensearch',
+                               'search': this.categoryPrefix + this.value,
+                               'suggest': ''
+                       },
+                       'dataType': 'json',
+                       'success': ve.bind( function ( data ) {
+                               var categoryList;
+                               this.matchingCategoriesQuery = null;
+                               this.matchingCategoriesRequest = null;
+                               if ( ve.isArray( data ) && data.length ) {
+                                       // Remove categoryPrefix from each of 
the items.
+                                       categoryList = data[1].map( ve.bind( 
function ( item ) {
+                                               return item.replace( new 
RegExp( this.categoryPrefix, 'gi' ),  '' );
+                                       }, this ) );
+                                       // Cache the matches to the query
+                                       this.matchingCategories[this.value] = 
categoryList;
+                                       this.populateMenu();
+                               } else {
+                                       // Don't repeat queries that resulted 
in invalid responses
+                                       this.matchingPages[this.value] = [];
+                               }
+                       }, this ),
+                       'complete': ve.bind( function () {
+                               this.popPending();
+                       }, this )
+               } );
+       }
+       return this;
+};
diff --git a/modules/ve/ui/widgets/ve.ui.MWLinkTargetInputWidget.js 
b/modules/ve/ui/widgets/ve.ui.MWLinkTargetInputWidget.js
index 25517c5..734c4ef 100644
--- a/modules/ve/ui/widgets/ve.ui.MWLinkTargetInputWidget.js
+++ b/modules/ve/ui/widgets/ve.ui.MWLinkTargetInputWidget.js
@@ -56,6 +56,8 @@
 
 ve.inheritClass( ve.ui.MWLinkTargetInputWidget, ve.ui.LinkTargetInputWidget );
 
+ve.mixinClass( ve.ui.MWLinkTargetInputWidget, ve.ui.PendingInputWidget );
+
 /* Methods */
 
 /**
@@ -138,7 +140,7 @@
        this.populateMenu();
        this.queryPageExistence();
        this.queryMatchingPages();
-       if ( this.value.length && !this.menu.isVisible() ) {
+       if ( this.value.length && $.trim( this.value ) !== '' && 
!this.menu.isVisible() ) {
                this.menu.show();
        }
        return this;
@@ -169,7 +171,7 @@
        // External link
        if ( ve.init.platform.getExternalLinkUrlProtocolsRegExp().test( 
this.value ) ) {
                items.push( new ve.ui.MenuSectionItemWidget(
-                       'externalLink', { '$$': menu$$, 'label': 'External 
link' }
+                       'externalLink', { '$$': menu$$, 'label': ve.msg( 
'visualeditor-linkinspector-suggest-external-link' ) }
                ) );
                items.push( new ve.ui.MenuItemWidget(
                        this.getExternalLinkAnnotationFromUrl( this.value ),
@@ -180,7 +182,7 @@
        // Internal link
        if ( !pageExists && ( !matchingPages || matchingPages.indexOf( 
this.value ) === -1 ) ) {
                items.push( new ve.ui.MenuSectionItemWidget(
-                       'newPage', { '$$': menu$$, 'label': 'New page' }
+                       'newPage', { '$$': menu$$, 'label': ve.msg( 
'visualeditor-linkinspector-suggest-new-page' ) }
                ) );
                items.push( new ve.ui.MenuItemWidget(
                        this.getInternalLinkAnnotationFromTitle( this.value ),
@@ -191,7 +193,7 @@
        // Matching pages
        if ( matchingPages && matchingPages.length ) {
                items.push( new ve.ui.MenuSectionItemWidget(
-                       'matchingPages', { '$$': menu$$, 'label': 'Matching 
page' }
+                       'matchingPages', { '$$': menu$$, 'label': ve.msg( 
'visualeditor-linkinspector-suggest-matching-page' ) }
                ) );
                for ( i = 0, len = matchingPages.length; i < len; i++ ) {
                        items.push( new ve.ui.MenuItemWidget(
@@ -212,30 +214,6 @@
        }
        this.menu.highlightItem( this.menu.getSelectedItem() );
 
-       return this;
-};
-
-/**
- * Signals that an response is pending.
- *
- * @method
- * @chainable
- */
-ve.ui.MWLinkTargetInputWidget.prototype.pushPending = function () {
-       this.pending++;
-       this.$.addClass( 've-ui-mwLinkTargetInputWidget-pending' );
-       return this;
-};
-
-/**
- * Signals that an response is complete.
- *
- * @method
- * @chainable
- */
-ve.ui.MWLinkTargetInputWidget.prototype.popPending = function () {
-       this.pending--;
-       this.$.removeClass( 've-ui-mwLinkTargetInputWidget-pending' );
        return this;
 };
 
diff --git a/modules/ve/ui/widgets/ve.ui.MenuWidget.js 
b/modules/ve/ui/widgets/ve.ui.MenuWidget.js
index 91175bb..7e8e727 100644
--- a/modules/ve/ui/widgets/ve.ui.MenuWidget.js
+++ b/modules/ve/ui/widgets/ve.ui.MenuWidget.js
@@ -34,9 +34,10 @@
 
        // Initialization
        this.$.hide().addClass( 've-ui-menuWidget' );
-       if ( !config.$input ) {
-               this.$.append( this.$input );
-       }
+       // TODO: Determine why this is needed as config would require input and 
$input properties ?
+       // if ( !config.$input ) {
+       //      this.$.append( this.$input );
+       // }
 };
 
 /* Inheritance */
diff --git a/modules/ve/ui/widgets/ve.ui.PendingInputWidget.js 
b/modules/ve/ui/widgets/ve.ui.PendingInputWidget.js
new file mode 100644
index 0000000..e0d883d
--- /dev/null
+++ b/modules/ve/ui/widgets/ve.ui.PendingInputWidget.js
@@ -0,0 +1,32 @@
+/*!
+ * VisualEditor UserInterface PendingInputWidget class.
+ *
+ * @copyright 2011-2013 VisualEditor Team and others; see AUTHORS.txt
+ * @license The MIT License (MIT); see LICENSE.txt
+ */
+
+/**
+ * Creates an ve.ui.PendingInputWidget object.
+ *
+ * @class
+ * @abstract
+ *
+ * @constructor
+ */
+ve.ui.PendingInputWidget = function VeUiPendingInputWidget () {
+       this.pending = 0;
+};
+
+/* Methods */
+
+ve.ui.PendingInputWidget.prototype.pushPending = function () {
+       this.pending++;
+       this.$.addClass( 've-ui-pendingInputWidget' );
+       return this;
+};
+
+ve.ui.PendingInputWidget.prototype.popPending = function () {
+       this.pending--;
+       this.$.removeClass( 've-ui-pendingInputWidget' );
+       return this;
+};

-- 
To view, visit https://gerrit.wikimedia.org/r/55853
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: newchange
Gerrit-Change-Id: I5eafaa484a1924a566d3a1ee1d869293089d0ecf
Gerrit-PatchSet: 1
Gerrit-Project: mediawiki/extensions/VisualEditor
Gerrit-Branch: master
Gerrit-Owner: Robmoen <rm...@wikimedia.org>

_______________________________________________
MediaWiki-commits mailing list
MediaWiki-commits@lists.wikimedia.org
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to