/** * NestedList provides a miller column interface to navigate between nested sets * and provide a clean interface with limited screen real-estate. * * @example * var data = { * text: 'Groceries', * items: [{ * text: 'Drinks', * items: [{ * text: 'Water', * items: [{ * text: 'Sparkling', * leaf: true * }, { * text: 'Still', * leaf: true * }] * }, { * text: 'Coffee', * leaf: true * }, { * text: 'Espresso', * leaf: true * }, { * text: 'Redbull', * leaf: true * }, { * text: 'Coke', * leaf: true * }, { * text: 'Diet Coke', * leaf: true * }] * }, { * text: 'Fruit', * items: [{ * text: 'Bananas', * leaf: true * }, { * text: 'Lemon', * leaf: true * }] * }, { * text: 'Snacks', * items: [{ * text: 'Nuts', * leaf: true * }, { * text: 'Pretzels', * leaf: true * }, { * text: 'Wasabi Peas', * leaf: true * }] * }] * }; * * Ext.define('ListItem', { * extend: 'Ext.data.Model', * config: { * fields: [{ * name: 'text', * type: 'string' * }] * } * }); * * var store = Ext.create('Ext.data.TreeStore', { * model: 'ListItem', * defaultRootProperty: 'items', * root: data * }); * * var nestedList = Ext.create('Ext.NestedList', { * fullscreen: true, * title: 'Groceries', * displayField: 'text', * store: store * }); */Ext.define('Ext.dataview.NestedList', { alternateClassName: 'Ext.NestedList', extend: 'Ext.Container', xtype: 'nestedlist', requires: [ 'Ext.layout.Card', 'Ext.dataview.List', 'Ext.TitleBar', 'Ext.Button', 'Ext.XTemplate', 'Ext.data.StoreManager', 'Ext.data.TreeStore', 'Ext.data.NodeStore' ], config: { /** * @cfg {String/Object/Boolean} cardSwitchAnimation * Animation to be used during transitions of cards. * @removed 2.0.0 please use {@link Ext.layout.Card#animation} */ /** * @cfg {String} backText * The label to display for the back button. * @accessor */ backText: 'Back', /** * @cfg {Boolean} useTitleAsBackText * `true` to use title as a label for back button. * @accessor */ useTitleAsBackText: true, /** * @cfg {Boolean} updateTitleText * Update the title text with the currently selected category. * @accessor */ updateTitleText: true, /** * @cfg {String} displayField * Display field to use when setting item text and title. * This configuration is ignored when overriding {@link #getItemTextTpl} or * {@link #getTitleTextTpl} for the item text or title. * @accessor */ displayField: 'text', /** * @cfg {String} loadingText * Loading text to display when a subtree is loading. * @accessor */ loadingText: 'Loading...', /** * @cfg {String} emptyText * Empty text to display when a subtree is empty. * @accessor */ emptyText: 'No items available.', /** * @cfg {Boolean/Function} onItemDisclosure * Maps to the {@link Ext.List#onItemDisclosure} configuration for individual lists. * @accessor */ onItemDisclosure: false, /** * @cfg {Boolean} allowDeselect * Set to `true` to allow the user to deselect leaf items via interaction. * @accessor */ allowDeselect: false, /** * @deprecated 2.0.0 Please set the {@link #toolbar} configuration to `false` instead * @cfg {Boolean} useToolbar `true` to show the header toolbar. * @accessor */ useToolbar: null, /** * @cfg {Ext.Toolbar/Object/Boolean} toolbar * The configuration to be used for the toolbar displayed in this nested list. * @accessor */ toolbar: { docked: 'top', xtype: 'titlebar', ui: 'light', inline: true }, /** * @cfg {String} title The title of the toolbar * @accessor */ title: '', /** * @cfg {String} layout * @hide * @accessor */ layout: { type: 'card', animation: { type: 'slide', duration: 250, direction: 'left' } }, /** * @cfg {Ext.data.TreeStore/String} store The tree store to be used for this nested list. */ store: null, /** * @cfg {Ext.Container} detailContainer The container of the `detailCard`. * A detailContainer is a reference to the container where a detail card * displays. * * See http://en.wikipedia.org/wiki/Miller_columns * * The two possible values for a detailContainer are undefined (default), * which indicates that a detailCard appear in the same container, or you * can specify a new container location. The default condition uses the * current List container. * * The following example shows creating a location for a detailContainer: * * var detailContainer = Ext.create('Ext.Container', { * layout: 'card' * }); * * var nestedList = Ext.create('Ext.NestedList', { * store: treeStore, * detailCard: true, * detailContainer: detailContainer * }); * * The default value is typically used for phone devices in portrait mode * where the small screen size dictates that the detailCard replace the * current container. * @accessor */ detailContainer: undefined, /** * @cfg {Ext.Component} detailCard provides the information for a leaf * in a Miller column list. In a Miller column, users follow a * hierarchial tree structure to a leaf, which provides information * about the item in the list. The detailCard lists the information at * the leaf. * * See http://en.wikipedia.org/wiki/Miller_columns * * @accessor */ detailCard: null, /** * @cfg {Object} backButton The configuration for the back button used in the nested list. */ backButton: { hidden: true }, /** * @cfg {Object} listConfig An optional config object which is merged with the default * configuration used to create each nested list. */ listConfig: null, /** * @cfg {Boolean} variableHeights * This configuration allows you optimize the picker by not having it read the DOM * heights of list items. */ variableHeights: false, /** * @private */ lastNode: null, /** * @private */ lastActiveList: null, ui: null, clearSelectionOnListChange: true }, baseCls: Ext.baseCSSPrefix + 'nested-list', /** * @private * @property {String} [listMode=title] * This hold the current list mode, values could be: `title`, `node`, `deep`. `title` when the * list is at the top level, `node` for first level and `deep` for any level lower than that. * This will be used by the `updateTitle` method in order to change the appropriate component's * text value. */ listMode: 'title', /** * @event itemtap * Fires when a node is tapped on. * @param {Ext.dataview.NestedList} this * @param {Ext.dataview.List} list The Ext.dataview.List that is currently active. * @param {Number} index The index of the item tapped. * @param {Ext.dom.Element} target The element tapped. * @param {Ext.data.Record} record The record tapped. * @param {Ext.event.Event} e The event object. */ /** * @event itemdoubletap * Fires when a node is double tapped on. * @param {Ext.dataview.NestedList} this * @param {Ext.dataview.List} list The Ext.dataview.List that is currently active. * @param {Number} index The index of the item that was tapped. * @param {Ext.dom.Element} target The element tapped. * @param {Ext.data.Record} record The record tapped. * @param {Ext.event.Event} e The event object. */ /** * @event containertap * Fires when a tap occurs and it is not on a template node. * @param {Ext.dataview.NestedList} this * @param {Ext.dataview.List} list The Ext.dataview.List that is currently active. * @param {Ext.event.Event} e The raw event object. */ /** * @event select * Fires when nodes are selected. * @param {Ext.dataview.NestedList} this * @param {Ext.dataview.List} list The Ext.dataview.List that is currently active. * @param {Array} selections Array of selected nodes. */ /** * @event deselect * Fires when nodes are deselected. * @param {Ext.dataview.NestedList} this * @param {Ext.dataview.List} list The Ext.dataview.List that is currently active. * @param {Array} selections Array of deselected nodes. */ /** * @event selectionchange * Fires when the selected nodes change. * @param {Ext.dataview.NestedList} this * @param {Ext.dataview.List} list The Ext.dataview.List that is currently active. * @param {Array} selections Array of nodes selected or deselected. */ /** * @event beforeselectionchange * Fires before a selection is made. * @param {Ext.dataview.NestedList} this * @param {Ext.dataview.List} list The Ext.dataview.List that is currently active. * @param {HTMLElement} node The node to be selected. * @param {Array} selections Array of currently selected nodes. * @deprecated 2.0.0 Please listen to the {@link #selectionchange} event with an order of * `before` instead. */ /** * @event listchange * Fires when the user taps a list item. * @param {Ext.dataview.NestedList} this * @param {Object} listitem The new active list. */ /** * @event leafitemtap * Fires when the user taps a leaf list item. * @param {Ext.dataview.NestedList} this * @param {Ext.List} list The subList the item is on. * @param {Number} index The index of the item tapped. * @param {Ext.dom.Element} target The element tapped. * @param {Ext.data.Record} record The record tapped. * @param {Ext.event.Event} e The event. */ /** * @event back * @preventable * Fires when the user taps Back. * @param {Ext.dataview.NestedList} this * @param {HTMLElement} node The node to be selected. * @param {Ext.dataview.List} lastActiveList The Ext.dataview.List that was last active. * @param {Boolean} detailCardActive Flag set if the detail card is currently active. */ /** * @event beforeload * Fires before a request is made for a new data object. * @param {Ext.dataview.NestedList} this * @param {Ext.data.Store} store The store instance. * @param {Ext.data.Operation} operation The Ext.data.Operation object that will be passed * to the Proxy to load the Store. */ /** * @event load * Fires whenever records have been loaded into the store. * @param {Ext.dataview.NestedList} this * @param {Ext.data.Store} store The store instance. * @param {Ext.util.Grouper[]} records An array of records. * @param {Boolean} successful `true` if the operation was successful. * @param {Ext.data.Operation} operation The associated operation. */ constructor: function(config) { if (Ext.isObject(config)) { if (config.getTitleTextTpl) { this.getTitleTextTpl = config.getTitleTextTpl; } if (config.getItemTextTpl) { this.getItemTextTpl = config.getItemTextTpl; } } this.callParent([config]); }, changeListMode: function(node) { var me = this, store = me.getStore(), rootNode = store && store.getRoot(); if (node === rootNode) { me.listMode = 'title'; } else if (node.parentNode === rootNode) { me.listMode = 'node'; } else { me.listMode = 'deep'; } }, onChildInteraction: function() { if (this.isGoingTo) { return false; } }, applyDetailContainer: function(config) { if (!config) { config = this; } return config; }, updateDetailContainer: function(newContainer, oldContainer) { if (newContainer) { newContainer.on('beforeactiveitemchange', 'onBeforeDetailContainerChange', this); newContainer.on('activeitemchange', 'onDetailContainerChange', this); } }, onBeforeDetailContainerChange: function() { this.isGoingTo = true; }, onDetailContainerChange: function() { this.isGoingTo = false; }, /** * Called when an list item has been tapped. * @param {Ext.List} list The subList the item is on. * @param {Number} location The id of the item tapped. * * @private */ onChildTap: function(list, location) { var me = this, hasListeners = me.hasListeners, record = location.record; if (me.onChildInteraction(list, location) === false) { return false; } if (hasListeners.childtap) { location.list = list; me.fireEvent('childtap', me, location); } if (hasListeners.itemtap) { me.fireEvent('itemtap', me, list, location.viewIndex, location.child, record, location.event ); } if (record.isLeaf()) { if (hasListeners.leafchildtap) { location.list = list; me.fireEvent('leafchildtap', me, location); } if (hasListeners.leafitemtap) { me.fireEvent('leafitemtap', me, list, location.viewIndex, location.child, record, location.event ); } me.goToLeaf(record); } else { this.goToNode(record); } }, onBeforeSelect: function() { this.fireEvent.apply(this, [].concat('beforeselect', this, Array.prototype.slice.call(arguments)) ); }, onContainerTap: function() { this.fireEvent.apply(this, [].concat('containertap', this, Array.prototype.slice.call(arguments)) ); }, onSelect: function() { var args = Array.prototype.slice.call(arguments); this.fireEvent.apply(this, [].concat('select', this, args)); this.onSelectionChange(args); }, onDeselect: function() { var args = Array.prototype.slice.call(arguments); this.fireEvent.apply(this, [].concat('deselect', this, args)); this.onSelectionChange(args); }, onSelectionChange: function(args) { this.fireEvent.apply(this, [].concat('selectionchange', this, args)); }, onChildDoubleTap: function(list, location) { var me = this, hasListeners = me.hasListeners; if (hasListeners.childdoubletap) { location.list = list; me.fireEvent('childdoubletap', me, location); } if (hasListeners.itemdoubletap) { me.fireEvent('itemdoubletap', me, list, location.viewIndex, location.child, location.record, location.event ); } }, onStoreBeforeLoad: function() { var loadingText = this.getLoadingText(); if (loadingText) { this.setMasked({ xtype: 'loadmask', message: loadingText }); } this.fireEvent.apply(this, [].concat('beforeload', this, Array.prototype.slice.call(arguments)) ); }, onStoreLoad: function(store, records, successful, operation, parentNode) { this.setMasked(false); this.fireEvent.apply(this, [].concat('load', this, Array.prototype.slice.call(arguments))); if (store.indexOf(this.getLastNode()) === -1) { this.goToNode(store.getRoot()); } }, /** * Called when the backButton has been tapped. */ onBackTap: function() { var me = this, node = me.getLastNode(), detailCard = me.getDetailCard(), detailCardActive = detailCard && me.getActiveItem() === detailCard, layout = me.getLayout(), animation = layout ? layout.getAnimation() : null, lastActiveList = me.getLastActiveList(); if (!animation || !(animation && animation.isAnimating)) { this.fireAction('back', [this, node, lastActiveList, detailCardActive], 'doBack', null, null, 'after' ); } }, doBack: function(me, node, lastActiveList, detailCardActive) { var layout = me.getLayout(), animation = layout ? layout.getAnimation() : null; if (detailCardActive && lastActiveList) { if (animation) { animation.setReverse(true); } me.setActiveItem(lastActiveList); me.setLastNode(node.parentNode); me.syncToolbar(); } else { me.goToNode(node.parentNode); } }, updateData: function(data) { if (!this.getStore()) { this.setStore(new Ext.data.TreeStore({ root: data })); } }, applyStore: function(store) { if (store) { if (Ext.isString(store)) { // store id store = Ext.data.StoreManager.get(store); } else { // store instance or store config if (!(store instanceof Ext.data.TreeStore)) { store = Ext.factory(store, Ext.data.TreeStore, null); } } // <debug> if (!store) { Ext.Logger.warn("The specified Store cannot be found", this); } //</debug> } return store; }, storeListeners: { rootchange: 'onStoreRootChange', load: 'onStoreLoad', beforeload: 'onStoreBeforeLoad' }, updateStore: function(newStore, oldStore) { var me = this, listeners = this.storeListeners; listeners.scope = me; if (oldStore && Ext.isObject(oldStore) && oldStore.isStore) { if (oldStore.autoDestroy) { oldStore.destroy(); } oldStore.un(listeners); } if (newStore) { newStore.on(listeners); me.goToNode(newStore.getRoot()); } }, onStoreRootChange: function(store, node) { this.goToNode(node); }, applyDetailCard: function(detailCard, oldDetailCard) { return Ext.factory(detailCard, Ext.Component, detailCard === null ? oldDetailCard : undefined ); }, applyBackButton: function(config) { var toolbar = this.getToolbar(); return !toolbar ? false : Ext.factory(config, Ext.Button, this.getBackButton()); }, updateBackButton: function(newButton, oldButton) { var me = this; if (newButton) { newButton.on('tap', me.onBackTap, me); newButton.setText(me.getBackText()); if (me.$backButtonContainer) { me.$backButtonContainer.insert(0, newButton); } else { me.getToolbar().insert(0, newButton); } } else if (oldButton) { oldButton.destroy(); } }, applyToolbar: function(config) { var containerConfig; if (config && config.splitNavigation) { Ext.apply(config, { docked: 'top', xtype: 'titlebar', ui: 'light' }); containerConfig = (config.splitNavigation === true) ? {} : config.splitNavigation; this.$backButtonContainer = this.add(Ext.apply({ xtype: 'toolbar', docked: 'bottom', hidden: true, ui: 'dark' }, containerConfig)); } return Ext.factory(config, Ext.TitleBar, this.getToolbar()); }, updateToolbar: function(newToolbar, oldToolbar) { var me = this; if (newToolbar) { newToolbar.setTitle(me.getTitle()); if (!newToolbar.getParent()) { me.add(newToolbar); } } else if (oldToolbar) { oldToolbar.destroy(); } }, updateUseToolbar: function(newUseToolbar, oldUseToolbar) { if (!newUseToolbar) { this.setToolbar(false); } }, updateTitle: function(newTitle) { var me = this, backButton = me.getBackButton(); if (me.getUpdateTitleText()) { if (me.listMode === 'title') { me.setToolbarTitle(newTitle); } else if (backButton && me.getUseTitleAsBackText() && me.listMode === 'node') { backButton.setText(newTitle); } } else { me.setToolbarTitle(newTitle); } }, /** * Override this method to provide custom template rendering of individual * nodes. The template will receive all data within the Record and will also * receive whether or not it is a leaf node. * @param {Ext.data.Record} node * @return {String} */ getItemTextTpl: function(node) { return '{' + this.getDisplayField() + '}'; }, /** * Override this method to provide custom template rendering of titles/back * buttons when {@link #useTitleAsBackText} is enabled. * @param {Ext.data.Record} node * @return {String} */ getTitleTextTpl: function(node) { return '{' + this.getDisplayField() + '}'; }, /** * @private */ renderTitleText: function(node, forBackButton) { var initialTitle; if (!node.titleTpl) { node.titleTpl = Ext.create('Ext.XTemplate', this.getTitleTextTpl(node)); } if (node.isRoot()) { initialTitle = this.getTitle(); return (forBackButton && initialTitle === '') ? this.getInitialConfig('backText') : initialTitle; } return node.titleTpl.applyTemplate(node.data); }, /** * Method to handle going to a specific node within this nested list. Node must be part of the * internal {@link #store}. * @param {Ext.data.NodeInterface} node The specified node to navigate to. */ goToNode: function(node) { var me = this, activeItem, detailCard, detailCardActive, reverse, firstList, secondList, layout, animation, list; if (!node) { return; } activeItem = me.getActiveItem(); detailCard = me.getDetailCard(); detailCardActive = detailCard && me.getActiveItem() === detailCard; reverse = me.goToNodeReverseAnimation(node); firstList = me.firstList; secondList = me.secondList; layout = me.getLayout(); animation = layout ? layout.getAnimation() : null; // if the node is a leaf, throw an error if (node.isLeaf()) { throw new Error('goToNode: passed a node which is a leaf.'); } // if we are currently at the passed node, do nothing. if (node === me.getLastNode() && !detailCardActive) { return; } if (detailCardActive) { if (animation) { animation.setReverse(true); } list = me.getLastActiveList(); list.getStore().setNode(node); node.expand(); me.setActiveItem(list); } else { if (animation) { animation.setReverse(reverse); } if (firstList && secondList) { // firstList and secondList have both been created activeItem = me.getActiveItem(); me.setLastActiveList(activeItem); list = (activeItem === firstList) ? secondList : firstList; list.getStore().setNode(node); node.expand(); me.setActiveItem(list); if (me.getClearSelectionOnListChange()) { list.deselectAll(); } } else if (firstList) { // only firstList has been created me.setLastActiveList(me.getActiveItem()); me.setActiveItem(me.getList(node)); me.secondList = me.getActiveItem(); } else { // no lists have been created me.setActiveItem(me.getList(node)); me.firstList = me.getActiveItem(); } } me.fireEvent('listchange', me, me.getActiveItem()); me.setLastNode(node); me.changeListMode(node); me.syncToolbar(); }, /** * The leaf you want to navigate to. You should pass a node instance. * @param {Ext.data.NodeInterface} node The specified node to navigate to. */ goToLeaf: function(node) { var me = this, card, container, sharedContainer, layout, animation, activeItem; if (!node.isLeaf()) { throw new Error('goToLeaf: passed a node which is not a leaf.'); } card = me.getDetailCard(); container = me.getDetailContainer(); sharedContainer = container === me; layout = me.getLayout(); animation = layout ? layout.getAnimation() : false; if (card) { if (container.getItems().indexOf(card) === -1) { container.add(card); } if (sharedContainer) { activeItem = me.getActiveItem(); if (activeItem instanceof Ext.dataview.List) { me.setLastActiveList(activeItem); } me.setLastNode(node); } if (animation) { animation.setReverse(false); } container.setActiveItem(card); me.syncToolbar(); } }, /** * @private * Method which updates the {@link #backButton} and {@link #toolbar} with * the latest information from the current node. */ syncToolbar: function(forceDetail) { var me = this, detailCard = me.getDetailCard(), node = me.getLastNode(), detailActive = forceDetail || (detailCard && (me.getActiveItem() === detailCard)), parentNode = (detailActive) ? node : node.parentNode, backButton = me.getBackButton(), toolbar = me.getToolbar(), splitNavigation; if (!toolbar) { return; } // show/hide the backButton, and update the backButton text, if one exists if (backButton) { splitNavigation = toolbar.getInitialConfig('splitNavigation'); if (splitNavigation) { me.$backButtonContainer[parentNode ? 'show' : 'hide'](); } backButton[parentNode ? 'show' : 'hide'](); if (parentNode && me.getUseTitleAsBackText()) { backButton.setText(me.renderTitleText(node.parentNode, true)); } } if (node) { me.setToolbarTitle(me.renderTitleText(node)); } }, updateBackText: function(newText) { var btn = this.getBackButton(); if (btn) { btn.setText(newText); } }, /** * @private * Returns `true` if the passed node should have a reverse animation from the * previous current node. * @param {Ext.data.NodeInterface} node */ goToNodeReverseAnimation: function(node) { var lastNode = this.getLastNode(); if (!lastNode) { return false; } return (!lastNode.contains(node) && lastNode.isAncestor(node)) ? true : false; }, /** * @private * Returns the list config for a specified node. * @param {HTMLElement} node The node for the list config. */ getList: function(node) { var me = this, treeStore = new Ext.data.NodeStore({ recursive: false, node: node, rootVisible: false, model: me.getStore().getModel(), proxy: 'memory' }), list; node.expand(); list = Ext.create(Ext.Object.merge({ xtype: 'list', pressedDelay: 250, autoDestroy: true, store: treeStore, onItemDisclosure: me.getOnItemDisclosure(), variableHeights: me.getVariableHeights(), emptyText: me.getEmptyText(), selectable: { deselectable: me.getAllowDeselect() }, listeners: { scope: me, childdoubletap: 'onChildDoubleTap', beforeselectionchange: 'onBeforeSelect', containertap: 'onContainerTap', select: 'onSelect', deselect: 'onDeselect', childtap: { fn: 'onChildTap', priority: 1000 }, childtouchstart: { fn: 'onChildInteraction', priority: 1000 } }, itemTpl: '<span<tpl if="leaf == true"> class="x-list-item-leaf"</tpl>>' + me.getItemTextTpl(node) + '</span>' }, me.getListConfig())); me.relayEvents(list, ['activate']); return list; }, privates: { /** * @private * This method will change the toolbar title without changing the List title. */ setToolbarTitle: function(newTitle) { var me = this, toolbar = me.getToolbar(); if (toolbar) { toolbar.setTitle(newTitle); } } }});