/***************************************************************************** * jQuery Finder v0.7 - Makes lists into a Finder, similar to Mac OS X * * @date $Date: 2009-02-27 13:53:16 +0200 (Fri, 27 Feb 2009) $ * @revision $Rev: 11 $ * @copy (c) Copyright 2009 Nicolas Rudas. All Rights Reserved. * @licence MIT Licensed * @discuss groups.google.com/group/jquery-en/browse_thread/thread/480bb6f722b66345 * @issues code.google.com/p/jqueryfinder/issues/ * @latest code.google.com/p/jqueryfinder/source * @demo nicolas.rudas.info/jquery/finder * ***************************************************************************** * Syntax: * $(selector).finder() Create a new finder with default options * * $(selector).finder(options) Create a new finder with additional options * * $(selector).finder(method,[arguments]) Execute a method on an existing finder * - select Select item * - refresh Reload currently selected item (cache is ignored) * - destroy Completely remove finder * **/ ;(function($){ $.fn.finder = function(o,m){ // Default options var defaults = { title : '', url : false, onInit : function(finderObj) {}, onRootInit : function(rootList,finderObj){}, onRootReady : function(newColumn,finderObj){}, onItemSelect : function(listItem,eventTarget,finderObject){ return false; }, onItemOpen : function(listItem,newColumn,self){}, onFolderSelect : function(listItem,eventTarget,finderObject){}, onFolderOpen : function(listItem,newColumn,self){}, processData : function(responseText) { return $('
').append(responseText); }, animate : true, cache : false, ajax : { cache : false }, listSelector : false, maxWidth : 450, classNames : { 'ui-finder' : 'ui-widget ui-widget-header', 'ui-finder-wrapper' : 'ui-widget-content', 'ui-finder-header' : undefined, 'ui-finder-title' : undefined, 'ui-finder-list-item-active' : 'ui-state-default', 'ui-finder-list-item-activeNow' : 'ui-state-hover', 'ui-finder-list-item-file' : 'ui-icon-document', 'ui-finder-list-item-folder' : 'ui-icon-folder-collapsed', 'ui-finder-icon-folder-arrow' : 'ui-icon ui-icon-triangle-1-e' }, toolbarActions : function() { return ''; } }; // Keep a reference to all finders created var Finders = $.Finders = $.Finders || {}; // Return a new timestamp // Usually used for caching URLs, or creating unique identifiers e.g. Finders[ timestamp() ] = new Finder(); var timestamp = function() { return parseInt(new Date().valueOf(),10); }; // Check if scrollTo Plugin exists var scrollToPlugin = $.scrollTo || false; if(typeof scrollToPlugin == 'function') { scrollToPlugin = true; $.scrollTo.defaults.axis = 'xy'; $.scrollTo.defaults.duration = 900; } // Set some variables (know what we are dealing with) var method, opts, url = (typeof m == 'string') ? m : null, func = (typeof m == 'function') ? m : null, _args = arguments; if(typeof o == 'string') { method = o; } else if (typeof o == 'object') { opts = o; } if(opts) { if(opts.classNames) { opts.classNames = jQuery.extend(defaults.classNames, opts.classNames); } opts = jQuery.extend(defaults, opts);} else { opts = defaults;} /** * Finder Constructor * * **/ function Finder(element,finderId){ var self = this; this.cache = {}; this._queue = []; this.settings = {}; this.id = finderId; // Reference to initial element - used when destroying Finder this.initial = $(element).clone(true); // Reference to element, used throughout this.element = $(element); this.element.attr('data-finder-ts',this.id); // make options internal properties for(var i in opts){ self.settings[i] = opts[i]; } return this; }; /** * Initialise Finder * Append necessary HTML, bind events etc * **/ Finder.prototype.init = function(){ var self = this, toolbarActions = this.settings.toolbarActions.apply(this.element) || '', classes = this.settings.classNames, uiFinder = classes['ui-finder'] || '', uiFinderWrapper = classes['ui-finder-wrapper'] || '', uiFinderHeader = classes['ui-finder-header'] || '', uiFinderTitle = classes['ui-finder-title'] || ''; // Wrap list to finder-wrapper this.element .wrap('
') .wrap('
') .wrap('
'); this.wrapper = this.element.parents('.ui-finder-container'); this.wrapper.parents('.ui-finder') .prepend('
'+toolbarActions+'
') .prepend('
'+this.settings.title+'
'); this.widget = this.wrapper.parents('.ui-finder'); this._toolbar = $('div.ui-finder-header',this.widget); this._title = $('div.ui-finder-title',this.widget); // Bind click events to wrapper so that only one event per finder is specified // Click event to handle showing of columns etc this.wrapper .unbind('click.FinderSelect') .bind('click.FinderSelect',function(e){ var event_target = e.target, $event_target = $(event_target); if(!$event_target.closest('li.ui-finder-list-item').length && !$event_target.is('> li.ui-finder-list-item').length || $event_target.parents('.ui-finder-column').length === 0 ) { return; } // Otherwise 'register' this action in queue self.queue($event_target); // And prevent any other browser actions return Boolean(self.lastSelectCallbackReturned); }); setTimeout(function() { self.settings.onInit.apply(self.element,[self]); self.settings.listItemBorderColour = $('.ui-widget-header').css('backgroundColor'); // Initialise root list self.selectItem('root'); },0); return this; }; Finder.prototype.title = function(val) { this._title.html(val); return this; }; Finder.prototype.toolbar = function(val) { this._toolbar.html(val); return this; }; /** * Queue - Following a click event on a list item or anchor, the queue function is called * It stores info about click events so that the script can handle click events * on a first-come first-served basis. * * @param noCache - True when queue function called via 'refresh' API * i.e. caching is false when refreshing * @param actionType - Either 'select' or 'open', specified only if queue fn * called via API (ie. selector.finder('select', ... )) **/ Finder.prototype.queue = function(target,noCache,actionType /* select or open */){ var self = this, wrapper = this.wrapper; this._queue.push( [target,noCache,actionType] ); // isProcessing is set to true when the Finder is currently 'doing stuff' // and set to false when not. So, if its not doing anything right now, // continue to process this event if(!self.isProcessing) { self.preSelect(); } return this; }; /** * preSelect - Simple function to determine which item to select * based on the current queue => Always first item in queue * (first-come, first-served) **/ Finder.prototype.preSelect = function(){ var self = this, q = this._queue; if(q.length==0) { return;} self.select.apply(self,q[0]); return this; }; /** * Select - Considering the target of a click event, this function determines * what to do next by taking into consideration if target was anchor, or list item, * and if target was a file or a folder. * * Note: - Cannot select an item which is not in page (i.e. in sublevels) * - When selecting item via API, not selecting levels properly **/ Finder.prototype.select = function(target,noCache,actionType) { var self = this, wrapper = this.wrapper, targetElement = (typeof target == 'string') ? $('a[rel="'+target+'"]',wrapper) : $(target), eventTarget = targetElement; if(typeof target.length != 'number') { throw 'jQuery Finder: Target must be either a URL or a jQuery/DOM element'; return this; } if(!targetElement[0]) { throw 'jQuery Finder: Target element does not exist'; return this; } this.isProcessing = true; var targetList = targetElement.closest('li.ui-finder-list-item'), targetA = $('> a:first',targetList), targetContainer = targetList.parents('div[data-finder-list-level]:first'), targetLevel = targetContainer.attr('data-finder-list-level'), type = (targetList.hasClass('ui-finder-file')) ? 'file' : 'folder', url = targetA.attr('rel'), wrapperLists = $('div.ui-finder-column:visible',wrapper), classes = this.settings.classNames, classesActive = classes['ui-finder-list-item-active'] || '', classesActiveNow = classes['ui-finder-list-item-activeNow'] || ''; // Fix event's target element. Only list and anchor elements make sense targetElement = (targetElement[0] !== targetList[0] && targetElement[0] !== targetA[0]) ? targetList : targetElement; // If select was triggered via API and target was a URL (e.g. finder('select',url)) // then target is considered to be the list item so as to select item and not open it. // This allows user to select an item by providing the URL of an anchor element // which would otherwise open the item if(actionType == 'select' /*&& typeof target == 'string' *//*&& type == 'file'*/) { eventTarget = targetElement = targetList; } // Currently selected item will no longer be active $('.ui-finder-list-item.ui-finder-list-item-activeNow',wrapper) .removeClass('ui-finder-list-item-activeNow ' + classesActiveNow ); // Remove visible lists which should not be visible anymore wrapperLists.each(function(){ var finderListWrapper = $(this), finderListLevel = finderListWrapper.attr('data-finder-list-level'); if( finderListLevel >= targetLevel ) { $('.ui-finder-list-item.ui-finder-list-item-active',finderListWrapper) .removeClass('ui-finder-list-item-active ' + classesActive ); } if( finderListLevel > targetLevel ) { finderListWrapper.remove(); } }); // Style selected list item // active refers to all previously selected list items // activeNow refers to the currently active list item targetList .addClass('ui-finder-list-item-active ' + classesActive) .addClass('ui-finder-list-item-activeNow ' + classesActiveNow); // Scroll to selected item // Mostly useful if item not selected following direct user action (e.g. click event) if(scrollToPlugin){ setTimeout(function() { targetContainer.scrollTo(targetList); }, 0); } // Call onSelectItem or onSelectFolder callbacks // If callback does not return false, // proceed to display item/folder in new column var selectCallback, callbackArgs = [targetList,eventTarget,self]; if (type == 'file') { selectCallback = self.settings.onItemSelect.apply(self.element,callbackArgs); } else { selectCallback = self.settings.onFolderSelect.apply(self.element,callbackArgs); } this.lastSelectCallbackReturned = selectCallback; // If callback returns false, no new column is added // If callback returns true, default browser action is taken (i.e. url followed) if( selectCallback !== false && selectCallback !== true) { // Notify user of loading action targetList.addClass('ui-finder-loading'); // Select item self.selectItem(url,noCache,[targetElement,targetList]); return this; } // Script will only reach this point when select callbacks return false or true // Adjust the width of the current columns // true param needed so that adjustWidth knows that // there are no new columns being added this.adjustWidth(true); // Finalise process (move on with queue etc) this.finalise(); return this; }; /** * Select Item * * This function retrieves the data to be shown to the user after a click event * Finder will only reach this point when select callbacks do not return false **/ Finder.prototype.selectItem = function(url,noCache,targets){ var self = this, settings = self.settings, target = (targets) ? targets[0] : null, listItem = (targets) ? targets[1] : null, type = (listItem) ? listItem[0].className.match(/(file|folder)/)[0] : 'folder', data = (url == 'root') ? (settings.url) ? null : this.element : $('> ul, > ol, > div',listItem).eq(0).clone(), url = (url == 'root' && typeof settings.url === 'string') ? settings.url : url; // Process data before appending new column var proceed = function(){ var processData = settings.processData, tmp_data = data; if($.isFunction(processData)) { data = processData(data); if(!data.length) { data = tmp_data;} } else { data = $(data); } // Store data in cache self.cache[url] = { 'url':url, 'data' : data, 'response': tmp_data, 'date': new Date().valueOf(), 'status' : 'success' }; // If just loaded root, call on root init callback if(url == settings.url && typeof settings.onRootInit === 'function') { settings.onRootInit.apply(self.element,[data,self]); } if(type == 'folder') { // Process data. Find list items and add necessary classes and icons $('> ul, > ol',data).eq(0).find('> li').each(function(){ var $this = $(this), thisType,thisTypeClass; // Get the type of this list item (file or folder) if($this.hasClass('ui-finder-folder')) { thisType = 'folder'; } else if ($this.hasClass('ui-finder-file')) { thisType = 'file'; } // If type (file or folder) is not specified try to figure it out else { if($this.children('ul,ol').length) { $this.addClass('ui-finder-folder'); thisType = 'folder'; } else { // default type is file $this.addClass('ui-finder-file'); thisType = 'file'; } } // Set class for icon thisTypeClass = (thisType == 'file') ? settings.classNames['ui-finder-list-item-file'] : settings.classNames['ui-finder-list-item-folder']; $this .addClass('ui-finder-list-item') .css('borderColor',settings.listItemBorderColour) .append(''); // Remove links var anch = $('> a',this), anchHref = anch.attr('href') || '_blank'+ new Date().valueOf(), anchTitle = anch.attr('title') || ''; if(anch.attr('rel') == anchHref.substring(1) || !anchHref.length) { return;} anch .attr('rel',anchHref) .attr('href',anchHref) .append(''); if(anchTitle.length == 0) {anch.attr('title',anchHref);} }); } // Append the new data self.appendNewColumn(url,data,[target,listItem],type); }; // Folder contents exist and no refresh if(data && data.length && !noCache) { proceed(); } // Folder content exist, and refresh, but no URL else if(data && data.length && noCache && url.match(/_blank\d+/)) { proceed(); } // Caching and data somewhere in cache else if( typeof this.cache[url] == 'object' && this.settings.cache && !noCache) { if(this.cache[url].status == 'success' ) { data = this.cache[url].data; proceed(); } } // No data yet, so retrieve from URL else if(!url.match(/_blank\d+/)) { $.ajax({ url : url, cache : self.settings.ajax.cache, success: function(response){ data = response; }, error: function(xhr,response){ data = response; }, complete : function(){ proceed(); } }); } return this; }; /*** * Append new Column - Function to append a new column to finder * called from selectItem function * * Triggers Callback functions for OpenItem or OpenFolder ! ***/ Finder.prototype.appendNewColumn = function(url,data,targets,type){ var self = this, target = (targets) ? targets[0] : null, listItem = (targets) ? targets[1] : null, targetParent = (listItem) ? listItem.parents('div[data-finder-list-level]:first') : null, columnId = url.replace(/[\W\s]*/g,''), columnLevel = (function(){ if (url == self.settings.url || url == 'root') { return 0; } return parseInt(targetParent.attr('data-finder-list-level'),10) + 1; })(); // If column already exists, remove it var newColumn = $('div[data-finder-list-id="'+columnId+'"]'); if(newColumn.length > 0) { newColumn[0].parentNode.removeChild(newColumn[0]); } // Specify new column, and add necessary attributes newColumn = $('
') // Avoid showing the column when it's not yet ready // Also, setting display to none makes DOM manipulation a bit faster .css('display','none') .attr('data-finder-list-id',columnId) .attr('data-finder-list-source',url) .attr('data-finder-list-level',columnLevel) .css('z-index',0); // Keep beneath other columns // Append new column // Plain DOM scripting used as opposed to jQuery as it's faster self.wrapper[0].appendChild(newColumn[0]); newColumn[0].appendChild($(data)[0]); // Adjust the width of the Finder // but make sure that column is appended & parsed (timeout = 0) setTimeout(function(){ self.adjustWidth(false,newColumn,url);},0); // Call onOpenItem or onOpenFolder callback if target was anchor // Note: target check necessary, root list has no target if(target && target[0] && target.is('a')) { var callbackArgs = [listItem,newColumn,self]; if(type == 'file') { self.settings.onItemOpen.apply(self.element,callbackArgs); } else { self.settings.onFolderOpen.apply(self.element,callbackArgs); } } return this; }; /*** * Adjust Width - Adjust the width of the columns and the wrapper element * param ignoreNew is true when select callbacks return false ***/ Finder.prototype.adjustWidth = function(ignoreNew,newColumn,url){ var self = this, wrapper = this.wrapper; newColumn = newColumn || $('div[data-finder-list-id]:visible:last',wrapper); // Get all siblings of the new column // i.e those visible and not the last, as new column is always last var columns = (!ignoreNew) ? wrapper.children('div[data-finder-list-id]:not(.ui-finder-new-col):visible') : wrapper.children('div[data-finder-list-id]:visible:not(:last)'), width = 0; newColumn.removeClass('ui-finder-new-col'); // Prevent previous columns from taking up all the space (width) columns.css('right','auto'); // Calculate the space taken by the visible columns // The total width of these columns will be set as // the left property of the new column (so that each column appears next to each other) columns.each(function() { $(this) .width('auto') // Explicitly setting the width of the column fixes some issues in IE. // The 20px padding is needed for Webkit browsers (don't know why) .width( $(this).outerWidth() + 20 ); width += $(this).width();}); // Account for Y axis scrollbar (only if it wasn't accounted before) // The value specified will be added to the new column's width var yScroll = 10,// ($.browser.msie && $.browser.version < 8) ? 10 : 5, accountYScroll = ( !newColumn.data('yscroll') ) ? yScroll : 0; // Need to know the width of the new column (newColumnWidth), // the total width of all columns (newWidth), // the current width of the wrapper element (currentWidth), // and the available width (specified in wrapper's parent) var _tmpNewColumnWidth = newColumn.width(), newColumnWidth = (self.settings.maxWidth && _tmpNewColumnWidth > self.settings.maxWidth) ? self.settings.maxWidth : _tmpNewColumnWidth + accountYScroll, newWidth = width + newColumnWidth, currentWidth = wrapper.width(), availableWidth = wrapper.parent().width(); // Mark column as y scrollbar fixed newColumn.data('yscroll',true); // Adjust the width of the wrapper element. As columns as absolutely positioned // no horizontal scrollbars appear if the total width of the columns exceeds the space available. // By setting the width of the wrapper element to that of the columns, a horizontal scrollbar appears. if ( newWidth > availableWidth || newWidth < currentWidth && currentWidth > availableWidth && newWidth != currentWidth) { // If going from multiple levels down (ie. many columns) to a higher level // (ie. to few columns) the new width will be less than available. // Also if theres only one column visible (ie. root) newWidth will equal newColumnWidth. // In these cases make sure Finder takes up all available space. if(newColumnWidth == newWidth || newWidth < availableWidth) { newWidth = 'auto'; } // Account for Y axis scrollbar // This adds the necessary width when moving backwards accountYScroll = ( newWidth != 'auto' && newWidth != currentWidth ) ? yScroll : ''; // Set width to new wrapper.width( newWidth + accountYScroll); } // As the column is absolutely positioned, its left property // must be specified newColumn.css('left',width); // Make the new column take up all available space // this must be set AFTER new column's width has been retrieved // otherwise the value is not true newColumn.css('right',0); // By setting the z-index of the new column to '2' // it prevents subsequent columns from being above it // whilst their css properties (left & right) are being set. // For this to be effective columns must have a background specified // (CSS class: .ui-finder-column) newColumn.css('z-index',2); // Set display to block so that we can scroll to the new column // Set visibility to hidden to avoid flicker if animation is required newColumn.css({'display':'block','visibility':'hidden'}); // Scroll to new column if(newColumn && scrollToPlugin){ this.wrapper.parent().scrollTo(newColumn); } // ignoreNew exists when select callbacks return FALSE // i.e. no new column was appended, but width of existing columns // and wrapper still need fixing if(!ignoreNew && this.settings.animate) { // Animate column if desired var duration = (!isNaN(this.settings.animate)) ? this.settings.animate : 500; // To animate the column we cannot use its width value (its 0) // but we can use its left property to calculate the width it currently occupies. // Pixels from left - total pixels = pixels available for the column (i.e. width) var fromLeft = newColumn.css('left').replace(/\D/g,''), fromRight = wrapper.width() - fromLeft; // So by setting the column's right property to the calculated value // and keeping its left property, the column becomes insivible // The animation then decreases the right property gradually to zero // to make the column visible newColumn .css('overflow-y','hidden') // avoid showing a scroll bar whilst animating .css('right',fromRight) .css('visibility','visible') .animate({'right':0 },{ duration:duration, complete:function(){ newColumn.css('overflow-y','scroll'); self.finalise(newColumn,url); } }); } // No animation, just show the new column else { newColumn.css('visibility','visible'); self.finalise(newColumn,url); } return this; }; Finder.prototype.finalise = function(newColumn,url){ // Remove any loading classes $('div.ui-finder-column .ui-finder-list-item.ui-finder-loading',this.wrapper) .removeClass('ui-finder-loading'); // Specify that script is done processing (used in queing) this.isProcessing = false; // Remove last item from queue // and if there are more items, move on this._queue.shift(); if(this._queue.length > 0) { this.preSelect(); } // If just loaded root, call on root ready callback if(url == this.settings.url && typeof this.settings.onRootReady === 'function') { this.settings.onRootReady.apply(this.element,[newColumn,this]); } return this; }; Finder.prototype.destroy = function(){ // Unbind events this.wrapper .unbind('click.FinderSelect'); // Remove Finder's HTML, append initial element this.element.parents('.ui-finder').replaceWith(this.initial); // Delete reference to Finder delete Finders[this.id]; return this; }; Finder.prototype.current = function(){ var current = $('.ui-state-hover',this.wrapper).find('a:first'); return (current.length>0) ? current : null; }; Finder.prototype.refresh = function(){ var current = this.current(); if(current) { this.queue(current,true); } else { this.selectItem('root',true); } return this; }; var _finder = Finders[ $(this).eq(0).attr('data-finder-ts') ]; if(method == 'current' && _finder) { return _finder.current(); } else if(method == 'get' && _finder) { return _finder; } return this.each(function(){ var finderId = $(this).attr('data-finder-ts') || null, timeStamp = new Date().valueOf(); // If name of method provided // execute method if(finderId && method) { var finder = Finders[finderId]; // Access private methods if(method == 'select' && m) { if(m.constructor == Array) { m = m.reverse(); for (var i = m.length - 1; i >= 0; i--){ finder.queue(m[i],false,method); } } else { finder.queue(m,false,method); } } else if(method == 'title') { finder.title(m); } else if(method == 'toolbar') { finder.toolbar(m); } else if(method == 'destroy') { finder.destroy(); } else if(method == 'refresh') { finder.refresh(); } } // If no method provided new finder is created else if (!method) { Finders[timeStamp] = new Finder(this,timeStamp).init(); } else if (!finderId && method) { throw 'jQuery Finder: Element is not a finder'; } }); };})(jQuery);