Ext JS TabPanel plugin for draggable tabs

Ext JS 4 comes with a plugin for TabPanels to allow reordering tabs using drag and drop. There isn’t an equivalent for Ext JS 3, however.

I came across a useful extension of the Ext JS 3.x TabPanel to re-order tabs via drag and drop. It is implemented as a subclass of Ext.TabPanel, as Ext.ux.panel.DDTabPanel and a version of it includes a useful reorder event that is fired once a tab is dragged to a new position in the TabPanel.

I refactored it from a subclass of Ext.TabPanel to a plugin so the original functionality is unchanged.

Demo

Here’s a simple screenshot of it in action

Tab 1 is being tragged in between Tab 2 and Tab 3

Or try out the demo. (View the demo source code to grab the code, or continue reading.)

Subclass or Plugin?

The original is a subclass of Ext.TabPanel. So to use for any of your existing subclasses of TabPanels, you have to change them to inherit this one.

However, I see this more as a feature of an Ext.TabPanel so it might be better provided as a plugin, rather than a subclass. Being a plugin means it could be reused in combination with various other plugins and potentially any manner of TabPanel subclasses.

For example, there may be other features you add to the TabPanel (via plugins) such as being able to rename a tab by double-clicking it (like Excel), or inserting an add tab button as the last tab, etc. These could all be plugins so you can choose which one(s) you want for your needs. But if they were all subclasses, trying to use a particular combination would mean you would need a particular subclass to represent the permutations. If you wanted another combination, you’d need another subclass, and so on. As a plugin, you can just pick and choose which other plugins you want as well.

So I attempted to refactor it as a plugin (without changing functionality).

The Code

The code involves 3 parts:

  1. The original subclass refactored into a plugin
  2. The accompanying DropTarget (only the constructor was changed)
  3. Example CSS to show the drop arrow (the blue one in the screenshot)

Original subclass refactored into a plugin

This is where the bulk of the changes are, but the functionality remains as is.

Ext.namespace('Ext.ux.panel');

/**
 * @class Ext.ux.panel.DDTabPanel
 * @author
 *     Original by
 *         <a href="http://extjs.com/forum/member.php?u=22731">thommy</a> and
 *         <a href="http://extjs.com/forum/member.php?u=37284">rizjoj</a><br />
 *     Published and polished by: Mattias Buelens (<a href="http://extjs.com/forum/member.php?u=41421">Matti</a>)<br />
 *     With help from: <a href="http://extjs.com/forum/member.php?u=1459">mystix</a>
 *     Polished and debugged by: Tobias Uhlig (info@internetsachen.com) 04-25-2009
 *     Ported to Ext-3.1.1 by: Tobias Uhlig (info@internetsachen.com) 02-14-2010
 *     Updated by <a href="http://www.sencha.com/forum/member.php?56442-brombs">brombs</a>
 *     to include reorder event
 *     Modified by <a href="http://www.onenaught.com">Anup Shah</a> to work as a plugin
 *     instead of subclass of TabPanel
 * @license Licensed under the terms of the Open Source <a href="http://www.gnu.org/licenses/lgpl.html">LGPL 3.0 license</a>.
 * Commercial use is permitted to the extent that the code/component(s) do NOT
 * become part of another Open Source or Commercially licensed development library
 * or toolkit without explicit permission.
 * @version 2.0.1 (Jan 11, 2013)
 */
Ext.ux.panel.DraggableTabs = Ext.extend(Object, {
	constructor: function (config) {
		if (config) {
			Ext.apply(this, config);
		}
	},
		
	init: function(tp) {
		if ((tp instanceof Ext.TabPanel) === false)
			return;

		// make these available onto the TabPanel as per original plugin, where used externally
		tp.arrowOffsetX = this.arrowOffsetX;
		tp.arrowOffsetY = this.arrowOffsetY;

		tp.addEvents('reorder');
			
		// TODO: check if ddGroupId can be left as a property of this plugin rather than on the TabPanel
		if (!tp.ddGroupId) {
			tp.ddGroupId = 'dd-tabpanel-group-' + tp.getId();
		}
			
		// New Event fired after drop tab. Is there a cleaner way to do this?
		tp.reorder = this.reorder;
		tp.oldinitTab = tp.initTab;
		tp.initTab = this.initTab;
		tp.onRemove = this.onRemove;

		tp.on('afterrender', this.afterRender, this);

		this.tabPanel = tp;
	},

	destroy: function () {
		tp.un('afterrender', this.afterRender, this);
		delete this.tabPanel;
		Ext.destroy(this.dd, this.arrow);
	},

	/**
	* @cfg {Number} arrowOffsetX The horizontal offset for the drop arrow indicator, in pixels (defaults to -9).
	*/
	arrowOffsetX: -9,
	/**
	* @cfg {Number} arrowOffsetY The vertical offset for the drop arrow indicator, in pixels (defaults to -8).
	*/
	arrowOffsetY: -8,

	reorder: function(tab) {
		this.fireEvent('reorder', this, tab);
	},
			
	// Declare the tab panel as a drop target
	/** @private */
	afterRender: function () {
		// Create a drop arrow indicator
		this.tabPanel.arrow = Ext.DomHelper.append(
			Ext.getBody(),
			'<div class="dd-arrow-down"></div>',
			true
		);
		this.tabPanel.arrow.hide();
		// Create a drop target for this tab panel
		var tabsDDGroup = this.tabPanel.ddGroupId;
		this.dd = new Ext.ux.panel.DraggableTabs.DropTarget(this, {
			ddGroup: tabsDDGroup
		});

		// needed for the onRemove-Listener
		this.move = false;
	},

	// Init the drag source after (!) rendering the tab
	/** @private */
	initTab: function (tab, index) {
		this.oldinitTab(tab, index);
			
		var id = this.id + '__' + tab.id;
		// Hotfix 3.2.0
		Ext.fly(id).on('click', function () { tab.ownerCt.setActiveTab(tab.id); });
		// Enable dragging on all tabs by default
		Ext.applyIf(tab, { allowDrag: true });

		// Extend the tab
		Ext.apply(tab, {
			// Make this tab a drag source
			ds: new Ext.dd.DragSource(id, {
				ddGroup: this.ddGroupId
				, dropEl: tab
				, dropElHeader: Ext.get(id, true)
				, scroll: false

				// Update the drag proxy ghost element
				, onStartDrag: function () {
					if (this.dropEl.iconCls) {

						var el = this.getProxy().getGhost().select(".x-tab-strip-text");
						el.addClass('x-panel-inline-icon');

						var proxyText = el.elements[0].innerHTML;
						proxyText = Ext.util.Format.stripTags(proxyText);
						el.elements[0].innerHTML = proxyText;

						el.applyStyles({
							paddingLeft: "20px"
						});
					}
				}

				// Activate this tab on mouse up
				// (Fixes bug which prevents a tab from being activated by clicking it)
				, onMouseUp: function (event) {
					if (this.dropEl.ownerCt.move) {
						if (!this.dropEl.disabled && this.dropEl.ownerCt.activeTab == null) {
							this.dropEl.ownerCt.setActiveTab(this.dropEl);
						}
						this.dropEl.ownerCt.move = false;
						return;
					}
					if (!this.dropEl.isVisible() && !this.dropEl.disabled) {
						this.dropEl.show();
					}
				}
			})
			// Method to enable dragging
			, enableTabDrag: function () {
				this.allowDrag = true;
				return this.ds.unlock();
			}
			// Method to disable dragging
			, disableTabDrag: function () {
				this.allowDrag = false;
				return this.ds.lock();
			}
		});

		// Initial dragging state
		if (tab.allowDrag) {
			tab.enableTabDrag();
		} else {
			tab.disableTabDrag();
		}
	}

	/** @private */
	, onRemove: function (c) {
		var te = Ext.get(c.tabEl);
		// check if the tabEl exists, it won't if the tab isn't rendered
		if (te) {
			// DragSource cleanup on removed tabs
			//Ext.destroy(c.ds.proxy, c.ds);
			te.select('a').removeAllListeners();
			Ext.destroy(te);
		}

		// ignore the remove-function of the TabPanel
		Ext.TabPanel.superclass.onRemove.call(this, c);

		this.stack.remove(c);
		delete c.tabEl;
		c.un('disable', this.onItemDisabled, this);
		c.un('enable', this.onItemEnabled, this);
		c.un('titlechange', this.onItemTitleChanged, this);
		c.un('iconchange', this.onItemIconChanged, this);
		c.un('beforeshow', this.onBeforeShowItem, this);

		// if this.move, the active tab stays the active one
		if (c == this.activeTab) {
			if (!this.move) {
				var next = this.stack.next();
				if (next) {
					this.setActiveTab(next);
				} else if (this.items.getCount() > 0) {
					this.setActiveTab(0);
				} else {
					this.activeTab = null;
				}
			}
			else {
				this.activeTab = null;
			}
		}
		if (!this.destroying) {
			this.delegateUpdates();
		}
	}
});

Ext.preg('draggabletabs', Ext.ux.panel.DraggableTabs);

The accompanying DropTarget to implement the drop behaviour

The only change from the original is the constructor:

// Ext.ux.panel.DraggableTabs.DropTarget
// Implements the drop behavior of the tab panel
/** @private */
Ext.ux.panel.DraggableTabs.DropTarget = Ext.extend(Ext.dd.DropTarget, {
	constructor: function (dd, config) {
		this.tabpanel = dd.tabPanel;
		// The drop target is the tab strip wrap
		Ext.ux.panel.DraggableTabs.DropTarget.superclass.constructor.call(this, this.tabpanel.stripWrap, config);
	}

	, notifyOver: function (dd, e, data) {
		var tabs = this.tabpanel.items;
		var last = tabs.length;

		if (!e.within(this.getEl()) || dd.dropEl == this.tabpanel) {
			return 'x-dd-drop-nodrop';
		}

		var larrow = this.tabpanel.arrow;

		// Getting the absolute Y coordinate of the tabpanel
		var tabPanelTop = this.el.getY();

		var left, prevTab, tab;
		var eventPosX = e.getPageX();

		for (var i = 0; i < last; i++) {
			prevTab = tab;
			tab = tabs.itemAt(i);
			// Is this tab target of the drop operation?
			var tabEl = tab.ds.dropElHeader;
			// Getting the absolute X coordinate of the tab
			var tabLeft = tabEl.getX();
			// Get the middle of the tab
			var tabMiddle = tabLeft + tabEl.dom.clientWidth / 2;

			if (eventPosX <= tabMiddle) {
				left = tabLeft;
				break;
			}
		}

		if (typeof left == 'undefined') {
			var lastTab = tabs.itemAt(last - 1);
			if (lastTab == dd.dropEl) return 'x-dd-drop-nodrop';
			var dom = lastTab.ds.dropElHeader.dom;
			left = (new Ext.Element(dom).getX() + dom.clientWidth) + 3;
		}

		else if (tab == dd.dropEl || prevTab == dd.dropEl) {
			this.tabpanel.arrow.hide();
			return 'x-dd-drop-nodrop';
		}

		larrow.setTop(tabPanelTop + this.tabpanel.arrowOffsetY).setLeft(left + this.tabpanel.arrowOffsetX).show();

		return 'x-dd-drop-ok';
	}

	, notifyDrop: function (dd, e, data) {
		this.tabpanel.arrow.hide();

		// no parent into child
		if (dd.dropEl == this.tabpanel) {
			return false;
		}
		var tabs = this.tabpanel.items;
		var eventPosX = e.getPageX();

		for (var i = 0; i < tabs.length; i++) {
			var tab = tabs.itemAt(i);
			// Is this tab target of the drop operation?
			var tabEl = tab.ds.dropElHeader;
			// Getting the absolute X coordinate of the tab
			var tabLeft = tabEl.getX();
			// Get the middle of the tab
			var tabMiddle = tabLeft + tabEl.dom.clientWidth / 2;
			if (eventPosX <= tabMiddle) break;
		}

		// do not insert at the same location
		if (tab == dd.dropEl || tabs.itemAt(i - 1) == dd.dropEl) {
			return false;
		}

		dd.proxy.hide();

		// if tab stays in the same tabPanel
		if (dd.dropEl.ownerCt == this.tabpanel) {
			if (i > tabs.indexOf(dd.dropEl)) i--;
		}

		this.tabpanel.move = true;
		var dropEl = dd.dropEl.ownerCt.remove(dd.dropEl, false);

		this.tabpanel.insert(i, dropEl);
		// Event drop
		this.tabpanel.fireEvent('drop', this.tabpanel);
		// Fire event reorder
		this.tabpanel.reorder(tabs.itemAt(i));

		return true;
	}

	, notifyOut: function (dd, e, data) {
		this.tabpanel.arrow.hide();
	}
});

CSS for the drop arrow

This of course can be customized. For example, you may prefer data URLs to inline the image (as the image is likely to be very small), but this illustrates what you’d need:

.dd-arrow-down.dd-arrow-down-invisible {
	display: none;
	visibility: hidden;
}

.dd-arrow-down {
	background-image: url(icons/tab-drop-arrow-down.png);
	display: block;
	visibility: visible;
	z-index: 20000;
	position: absolute;
	width: 16px;
	height: 16px;
	top: 0;
	left: 0;
}

Example usage

As a plugin, you simply add it to the plugins configuration when constructing an instance of an Ext.TabPanel (or a subclass, potentially):

plugins: new Ext.ux.panel.DraggableTabs(),

Here’s an example of how you might use it (including the use of the new reorder event):

var tabPanel = new Ext.TabPanel({
	plugins: new Ext.ux.panel.DraggableTabs(),
	renderTo: document.body,
	activeTab: 0,
	width:300,
	height:150,
	items:[{
		title: 'Tab 1',
		html: "Tab 1 contents"
	},{
		title: 'Tab 2',
		html: "Tab 2 contents"
	},{
		title: 'Tab 3',
		html: "Tab 3 contents"
	}],
	listeners: {
		reorder: {
			fn: function (tabPanel, movedTab) {
				// do something here
			}
		}
	}
});

The above example is simplistic. Drag and drop will work on disabled tabs, tabs that are dynamically added (the demo has an example of this), etc.

Limitations

Any limitations are listed in the original form post (I’ve not changed the functionality – at least not knowingly!)

The main limitation here is that the original class and this refactored plugin is for Ext JS 3.x, not the latest Ext JS 4.x

If anyone’s had the time to create one for Ext JS 4.x please do let me know!
Update: Turns out that for Ext JS 4, there is built in plugin. More further below.

What about Ext.NET 1.x?

This plugin can be used with Ext.NET 1.x. (For Ext.NET 2, which is based on Ext JS 4.*, see below). Assuming the JavaScript has been referenced, here is an example of how it can be used:

<ext:TabPanel runat="server" Height="150" Width="300" TabPosition="Bottom" >
	<Plugins>
		<ext:GenericPlugin runat="server" InstanceName="Ext.ux.panel.DraggableTabs" />
	</Plugins>
	<Items>
		<ext:Panel runat="server" Title="Tab1" Border="false" Closable="true" Html="Tab 1 contents" />
		<ext:Panel runat="server" Title="Tab2" Border="false" Closable="true" Html="Tab 2 contents" />
		<ext:Panel runat="server" Title="Tab3" Border="false" Closable="true" Html="Tab 3 contents" />
	</Items>
</ext:TabPanel>

What about Ext JS 4 and Ext.NET 2?

Turns out Ext JS 4 includes Ext.ux.TabReorderer which is also a plugin.

For Ext.NET 2, it is part of the BoxReorderer plugin so you only need this:

<ext:TabPanel runat="server" Height="150" Width="300">
	<Plugins>
		<ext:BoxReorderer />
	</Plugins>
	<Items>
		<ext:Panel runat="server" Title="Tab1" Border="false" Closable="true" Html="Tab 1 contents" />
		<ext:Panel runat="server" Title="Tab2" Border="false" Closable="true" Html="Tab 2 contents" />
		<ext:Panel runat="server" Title="Tab3" Border="false" Closable="true" Html="Tab 3 contents" />
	</Items>
</ext:TabPanel>

Credits

All credits go to the original authors for the useful functionality.

Help

Let me know if you spot any problems or better practices in the conversion to a plugin. If you have improvements to the actual functionality, please post it to the original thread on the Sencha forums.

One thought on “Ext JS TabPanel plugin for draggable tabs

Leave a Reply

Your email address will not be published. Required fields are marked *