/** * NestedList provides a miller column interface to navigate between nested sets * and provide a clean interface with limited screen real-estate. * * @example miniphone preview * 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.dataview.List', 'Ext.TitleBar', 'Ext.Button', 'Ext.XTemplate', 'Ext.data.StoreManager', 'Ext.data.TreeStore', 'Ext.data.NodeStore' ], config: { /** * @cfg * @inheritdoc */ baseCls: Ext.baseCSSPrefix + 'nested-list', /** * @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} useSimpleItems * Set this to false if you want the lists in this NestedList to create complex container list items. */ useSimpleItems: true, /** * @cfg {Number} itemHeight * This allows you to set the default item height and is used to roughly calculate the amount * of items needed to fill the list. By default items are around 50px high. If you set this * configuration in combination with setting the {@link #variableHeights} to false you * can improve the scrolling speed */ itemHeight: null, /** * @cfg {Boolean} variableHeights * This configuration allows you optimize the picker by not having it read the DOM heights of list items. * Instead it will assume (and set) the height to be the {@link #itemHeight}. */ variableHeights: false, /** * @private */ lastNode: null, /** * @private */ lastActiveList: null, ui: null, clearSelectionOnListChange: true }, /** * @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 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 the selected nodes. */ /** * @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'; } }, onItemInteraction: 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} index The id of the item tapped. * @param {Ext.Element} target The list item tapped. * @param {Ext.data.Record} record The record which as tapped. * @param {Ext.event.Event} e The event. */ onItemTap: function (list, index, target, record, e) { var me = this, store = list.getStore(), node = store.getAt(index); me.fireEvent('itemtap', this, list, index, target, record, e); if (node.isLeaf()) { me.fireEvent('leafitemtap', this, list, index, target, record, e); me.goToLeaf(node); } else { this.goToNode(node); } }, 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))); }, onSelectionChange: function () { this.fireEvent.apply(this, [].concat('selectionchange', this, Array.prototype.slice.call(arguments))); }, onItemDoubleTap: function () { this.fireEvent.apply(this, [].concat('itemdoubletap', this, Array.prototype.slice.call(arguments))); }, onStoreBeforeLoad: function () { var loadingText = this.getLoadingText(), scroller = this.getScrollable(); if (loadingText) { this.setMasked({ xtype: 'loadmask', message: loadingText }); //disable scrolling while it is masked if (scroller) { scroller.setDisabled(true); } } 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, lastActiveList = me.getLastActiveList(); this.fireAction('back', [this, node, lastActiveList, detailCardActive], 'doBack'); }, 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) { return Ext.factory(config, Ext.Button, this.getBackButton()); }, updateBackButton: function (newButton, oldButton) { if (newButton) { var me = this, toolbar; newButton.on('tap', me.onBackTap, me); newButton.setText(me.getBackText()); toolbar = me.getToolbar(); if (me.$backButtonContainer) { me.$backButtonContainer.insert(0, newButton); } else { toolbar.insert(0, newButton); } } else if (oldButton) { oldButton.destroy(); } }, applyToolbar: function (config) { if (config && config.splitNavigation) { Ext.apply(config, { docked: 'top', xtype: 'titlebar', ui: 'light' }); var 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) { if (!node.titleTpl) { node.titleTpl = Ext.create('Ext.XTemplate', this.getTitleTextTpl(node)); } if (node.isRoot()) { var 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) { if (!node) { return; } var me = this, 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, list; //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) { if (!node.isLeaf()) { throw new Error('goToLeaf: passed a node which is not a leaf.'); } var me = this, card = me.getDetailCard(), container = me.getDetailContainer(), sharedContainer = container === me, layout = me.getLayout(), animation = layout ? layout.getAnimation() : false, activeItem; 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(); //show/hide the backButton, and update the backButton text, if one exists if (backButton) { var toolbar = me.getToolbar(), 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) { this.getBackButton().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' }); node.expand(); return Ext.Object.merge({ xtype: 'list', useSimpleItems: me.getUseSimpleItems(), pressedDelay: 250, autoDestroy: true, store: treeStore, onItemDisclosure: me.getOnItemDisclosure(), allowDeselect: me.getAllowDeselect(), itemHeight: me.getItemHeight(), variableHeights: me.getVariableHeights(), emptyText: me.getEmptyText(), listeners: [{ itemdoubletap: 'onItemDoubleTap', itemtap: 'onItemTap', beforeselectionchange: 'onBeforeSelect', containertap: 'onContainerTap', scope: me }, { selectionchange: 'onSelectionChange', itemtouchstart: 'onItemInteraction', itemtap: 'onItemInteraction', order: 'before', scope: me }], itemTpl: '<span<tpl if="leaf == true"> class="x-list-item-leaf"</tpl>>' + me.getItemTextTpl(node) + '</span>' }, me.getListConfig()); }, 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); } } }});