Source: deliteful/Accordion.js

/** @module deliteful/Accordion */
define([
	"dcl/dcl",
	"decor/sniff",
	"requirejs-dplugins/Promise!",
	"delite/register",
	"delite/KeyNav",
	"requirejs-dplugins/jquery!attributes/classes",
	"delite/DisplayContainer",
	"./ToggleButton",
	"./features",
	"delite/theme!./Accordion/themes/{{theme}}/Accordion.css"
], function (dcl, has, Promise, register, KeyNav, $, DisplayContainer, ToggleButton) {

	function setVisibility(node, val) {
		node.style.display = val ? "" : "none";
	}

	function listenAnimationEndEvent(element) {
		return new Promise(function (resolve) {
			var handler = element.on(has("animationEndEvent"), function () {
				handler.remove();
				resolve();
			});
		});
	}

	/* Accordion modes */
	var accordionModes = {
		singleOpen: "singleOpen", // default
		multipleOpen: "multipleOpen"
	};

	var defaultMode = accordionModes.singleOpen;

	/**
	 * A layout container that displays a vertically stacked list of Panels whose titles are all visible, but only one
	 * or at least one panel's content is visible at a time (depending on the `mode` property value).
	 *
	 * Once the panels are in an accordion, they become collapsible Panels by replacing their headers by ToggleButtons.
	 *
	 * When a panel is open, it fills all the available space with its content.
	 *
	 * @example
	 * <d-accordion id="accordion" selectedChildId="panel1">
	 *     <d-panel id="panel1">...</d-panel>
	 *     <d-panel id="panel2">...</d-panel>
	 *     <d-panel id="panel3">...</d-panel>
	 * </d-accordion>
	 * @class module:deliteful/Accordion
	 * @augments module:delite/DisplayContainer
	 */
	return register("d-accordion", [HTMLElement, DisplayContainer, KeyNav], /** @lends module:deliteful/Accordion# */ {
		/**
		 * The name of the CSS class of this widget.
		 * @member {string}
		 * @default "d-accordion"
		 */
		baseClass: "d-accordion",

		/**
		 * The id of the panel to be open at initialization.
		 * If not specified, the default open panel is the first one.
		 * @member {string}
		 * @default ""
		 */
		selectedChildId: "",

		/**
		 * The mode of the Accordion
		 * `mode` is one of `["singleOpen", "multipleOpen"]`.
		 * @member {string}
		 * @default "singleOpen"
		 */
		mode: defaultMode,

		/**
		 * If true, animation is used when a panel is opened or closed.
		 * @member {boolean}
		 * @default true
		 */
		animate: true,

		/**
		 * The default CSS class to apply to DOMNode in children headers to make them display an icon when they are
		 * open. If a child panel has its own iconClass specified, that value is used on that panel.
		 * @member {string}
		 * @default ""
		 */
		openIconClass: "",

		/**
		 * The default CSS class to apply to DOMNode in children headers to make them display an icon when they are
		 * closed. If a child panel has its own closedIconClass specified, that value is used on that panel.
		 * @member {string}
		 * @default ""
		 */
		closedIconClass: "",

		/**
		 * List of upgraded panels (i.e. `<d-panel>` widgets that have already run createdCallback() and
		 * attachedCallback()).
		 * @member {module:delite/Panel[]}
		 */
		_panelList: null,

		_numOpenPanels: 0,

		_changeHandler: function (event) {
			var panel = event.target.parentNode;
			// Case when the event is fired by the label or the icon
			if (panel.nodeName.toLowerCase() !== "d-panel") {
				panel = panel.parentNode;
			}
			switch (this.mode) {
			case accordionModes.singleOpen :
				this.show(panel);
				break;
			case accordionModes.multipleOpen :
				if (panel.open) {
					this.hide(panel);
				} else {
					this.show(panel);
				}
				break;
			default :
				break;
			}
		},

		_setupUpgradedChild: function (panel) {
			// TODO: To change when https://github.com/ibm-js/delite/issues/414 be solved
			var toggle = new ToggleButton({
				label: panel.label,
				iconClass: panel.closedIconClass || this.closedIconClass,
				checkedIconClass: panel.iconClass || this.openIconClass,
				id: panel.id + "_button"
			});
			toggle.placeAt(panel.headerNode, "replace");
			// React to programmatic changes on the panel to update the button
			panel.observe(function (oldValues) {
				if ("label" in oldValues) {
					this.headerNode.label = this.label;
				}
				if ("iconClass" in oldValues) {
					this.headerNode.checkedIconClass = this.iconClass;
				}
				if ("closedIconClass" in oldValues) {
					this.headerNode.iconClass = this.closedIconClass;
				}
			}.bind(panel));
			toggle.on("click", this._changeHandler.bind(this));
			panel.headerNode = toggle;
			setVisibility(panel.containerNode, false);
			panel.open = false;
			// Setting initial WAI-ARIA properties
			panel.headerNode.setAttribute("tabindex", "-1");
			panel.headerNode.setAttribute("role", "tab");
			panel.headerNode.setAttribute("aria-expanded", "false");
			panel.headerNode.setAttribute("aria-selected", "false");
			panel.containerNode.setAttribute("role", "tabpanel");
			panel.containerNode.setAttribute("aria-labelledby", panel.headerNode.id);
			panel.containerNode.setAttribute("aria-hidden", "true");
			return panel;
		},

		_setupNonUpgradedChild: function (panel) {
			panel.accordion = this;
			panel.addEventListener("customelement-attached", this._attachedlistener = function () {
				this.removeEventListener("customelement-attached", this.accordion._attachedlistener);
				this.accordion._panelList.push(this.accordion._setupUpgradedChild(this));
			}.bind(panel));
		},

		createdCallback: function () {
			this._panelList = [];

			// Declarative case (panels specified declaratively inside the declarative Accordion).
			var panels = this.querySelectorAll("d-panel");
			for (var i = 0, l = panels.length; i < l; i++) {
				if (!panels[i].attached) {
					this._setupNonUpgradedChild(panels[i]);
				} else {
					this._panelList.push(this._setupUpgradedChild(panels[i]));
				}
			}
		},

		/**
		 * If no panel is shown then show the first one.
		 * @private
		 */
		_showOpenPanel: function () {
			if (!this._selectedChild && this._panelList.length > 0) {
				this._selectedChild = this._panelList[0];
				this.show(this._selectedChild);
			}
		},

		computeProperties: function (props) {
			if ("mode" in props) {
				if (!(this.mode in accordionModes)) {
					this.mode = props.mode;
				}
			}
		},

		/* jshint maxcomplexity: 14 */
		refreshRendering: function (props) {
			if ("selectedChildId" in props) {
				var childNode = this.ownerDocument.getElementById(this.selectedChildId);
				if (childNode) {
					if (childNode.attached) {
						if (childNode !== this._selectedChild) { // To avoid calling show() method twice
							if (!this._selectedChild) { // If selectedChild is not initialized, then initialize it
								this._selectedChild = childNode;
							}
							this.show(childNode);
						}
					} else {
						// TODO: don't we need to set up a listener for when the child finishes initializing,
						// to call show() then?
						this._selectedChild = childNode;
					}
				}
			}

			// If no panel was selected, then show the first one.
			if (("attached" in props || "_panelList" in  props) && this._panelList.length > 0) {
				this._showOpenPanel();
			}

			if ("openIconClass" in props) {
				this.getChildren().forEach(function (panel) {
					if (panel.attached && !panel.iconClass) {
						panel.headerNode.checkedIconClass = this.openIconClass;
					}
				}.bind(this));
			}
			if ("closedIconClass" in props) {
				this.getChildren().forEach(function (panel) {
					if (panel.attached && !panel.closedIconClass) {
						panel.headerNode.iconClass = this.closedIconClass;
					}
				}.bind(this));
			}
			if ("mode" in props) {
				this.setAttribute("aria-multiselectable", this.mode === accordionModes.multipleOpen);
				if (this.mode === accordionModes.singleOpen) {
					this._showOpenPanel();
					this._panelList.forEach(function (panel) {
						if (panel.open && panel !== this._selectedChild) {
							this.hide(panel);
						}
					}.bind(this));
				}
			}
		},
		/* jshint maxcomplexity: 10 */

		_useAnimation: function () {
			return (this.animate && (function () {
				// Animation events are broken if the widget is not visible
				var parent = this;
				while (parent && parent.style.display !== "none" && parent !== this.ownerDocument.documentElement) {
					parent = parent.parentNode;
				}
				var visible =  parent === this.ownerDocument.documentElement;

				// Flexbox animation is not supported on IE
				// TODO: Create a feature test for flexbox animation
				return (!!has("animationEndEvent") && visible && (!has("ie")));
			}.bind(this))());
		},

		_doTransition: function (panel, params) {
			var promise;
			if (params.hide) {
				if (this._useAnimation()) {
					// To avoid hiding the panel title bar on animation
					panel.style.minHeight = window.getComputedStyle(panel.headerNode).getPropertyValue("height");
					$(panel).addClass("d-accordion-closeAnimation").removeClass("d-accordion-open-panel");
					$(panel.containerNode).removeClass("d-panel-content-open");
					panel.containerNode.style.overflow = "hidden"; //To avoid scrollBar on animation
					promise = listenAnimationEndEvent(panel).then(function () {
						setVisibility(panel.containerNode, panel.open);
						$(panel).removeClass("d-accordion-closeAnimation");
						panel.containerNode.style.overflow = "";
						panel.style.minHeight = "";
					});
				} else {
					$(panel).removeClass("d-accordion-open-panel");
					$(panel.containerNode).removeClass("d-panel-content-open");
					setVisibility(panel.containerNode, false);
				}
			} else {
				if (this._useAnimation()) {
					// To avoid hiding the panel title bar on animation
					panel.style.minHeight = window.getComputedStyle(panel.headerNode).getPropertyValue("height");
					$(panel).addClass("d-accordion-openAnimation");
					$(panel.containerNode).addClass("d-panel-content-open");
					setVisibility(panel.containerNode, true);
					panel.containerNode.style.overflow = "hidden"; //To avoid scrollBar on animation
					promise = listenAnimationEndEvent(panel).then(function () {
						$(panel).addClass(function () {
							return panel.open ? "d-accordion-open-panel" : "";
						}).removeClass("d-accordion-openAnimation");
						panel.containerNode.style.overflow = "";
						panel.style.minHeight = "";
					});
				} else {
					$(panel).addClass("d-accordion-open-panel");
					$(panel.containerNode).addClass("d-panel-content-open");
					setVisibility(panel.containerNode, true);
				}
			}
			return Promise.resolve(promise);
		},

		changeDisplay: function (widget, params) {
			var valid = true, promises = [];
			if (params.hide) {
				if (widget.open) {
					if (this._numOpenPanels > 1) {
						this._numOpenPanels--;
						widget.open = false;
						widget.headerNode.checked = false;
					} else {
						widget.headerNode.checked = true;
						valid = false;
					}
				} else {
					widget.headerNode.checked = false;
					valid = false;
				}
			} else {
				if (!widget.open) {
					this._numOpenPanels++;
					if (this.mode === accordionModes.singleOpen) {
						var origin = this._selectedChild;
						this._selectedChild = widget;
						this.selectedChildId = widget.id;
						if (origin !== widget) {
							promises.push(this.hide(origin));
						}
					}
					widget.open = true;
					widget.headerNode.checked = true;
				} else {
					widget.headerNode.checked = true;
					valid = false;
				}
			}
			if (valid) {
				promises.push(this._doTransition(widget, params));
				//Updating WAI-ARIA properties
				widget.headerNode.setAttribute("aria-selected", widget.open);
				widget.headerNode.setAttribute("aria-expanded", widget.open);
				widget.containerNode.setAttribute("aria-hidden", !widget.open);
			}
			return Promise.all(promises);
		},

		/**
		 * This method must be called to hide the content of a particular child Panel on this container.
		 * The parameter 'params' is optional and only used to specify the content to load on the panel specified.
		 * @method module:deliteful/Accordion#hide
		 * @param {Element|string} dest - Element or Element id that points to the Panel whose content must be hidden
		 * @param {Object} [params] - A hash like {contentId: "newContentId"}. The 'contentId' is the id of the element
		 * to load as content of the Panel.
		 * @returns {Promise} A promise that will be resolved when the display and transition effect will have
		 * been performed.
		 * @fires module:delite/DisplayContainer#delite-display-load
		 * @fires module:delite/DisplayContainer#delite-before-hide
		 * @fires module:delite/DisplayContainer#delite-after-hide
		 */

		/**
		 * This method must be called to display the content of a particular child Panel on this container.
		 * The parameter 'params' is optional and only used to specify the content to load on the panel specified.
		 * loadChild() is used to do this, so a controller could load/create the content by listening the
		 * `delite-display-load` event.
		 * @method module:deliteful/Accordion#show
		 * @param {Element|string} dest - Element or Element id that points to the Panel whose content must be shown
		 * @param {Object} [params] - A hash like {contentId: "newContentId"}. The 'contentId' is the id of the element
		 * to load as content of the Panel.
		 * @returns {Promise} A promise that will be resolved when the display and transition effect will have
		 * been performed.
		 * @fires module:delite/DisplayContainer#delite-display-load
		 * @fires module:delite/DisplayContainer#delite-before-show
		 * @fires module:delite/DisplayContainer#delite-after-show
		 */

		/**
		 * This method must be called to load a particular child on this container.
		 * A `delite-display-load` event is fired giving the chance to a controller to load/create the child by using
		 * the event's setChild() method (the child has to be a deliteful/Panel Element) and/or to load/create
		 * the content for a Panel by using the event's setContent() method. This last method takes as first parameter
		 * the deliteful/Panel Element and as second parameter the Element to load as content for the panel.
		 * @method
		 * @param {Element|string} dest  - Element or Element id that points to the child this container must
		 * load.
		 * @param {Object} [params] - A hash like {contentId: "newContentId"}. The 'contentId' is the id passed to the
		 * controller to load/create an element as content of the Panel.
		 * @returns {Promise} A promise that will be resolved when the child will have been
		 * loaded with an object of the following form: `{ child: panelElement }` or with an optional index
		 * `{ child: panelElement, index: index }`.
		 * @fires module:delite/DisplayContainer#delite-display-load
		 */
		loadChild: dcl.superCall(function (sup) {
			return function (dest, params) {
				var event = {
					setContent: function (panel, content) {
						panel.replaceChild(content, panel.containerNode);
						panel.containerNode = content;
						$(panel.containerNode).addClass("d-panel-content");
					}
				};
				dcl.mix(event, params);
				return sup.apply(this, [dest, event]);
			};
		}),

		onAddChild: dcl.superCall(function (sup) {
			return function (node) {
				var res = sup.call(this, node);
				this._panelList.push(this._setupUpgradedChild(node));
				this.notifyCurrentValue("_panelList");
				return res;
			};
		}),

		_onRemoveChild: function (event) {
			this._panelList.splice(this._panelList.indexOf(event.child), 1);
			this.notifyCurrentValue("_panelList");
		},

		//////////// delite/KeyNav implementation ///////////////////////////////////////
		// Keyboard navigation is based on WAI-ARIA Pattern for Accordion:
		// http://www.w3.org/TR/2013/WD-wai-aria-practices-20130307/#accordion
		descendantSelector: "d-panel>.d-toggle-button",

		previousKeyHandler: function () {
			var focusedPanel = this.navigatedDescendant.parentNode;
			this.navigateTo(focusedPanel.previousElementSibling ? focusedPanel.previousElementSibling.headerNode
				: this.lastElementChild.headerNode);
		},

		nextKeyHandler: function () {
			var focusedPanel = this.navigatedDescendant.parentNode;
			this.navigateTo(focusedPanel.nextElementSibling ? focusedPanel.nextElementSibling.headerNode
				: this.firstElementChild.headerNode);
		},

		upKeyHandler: function () {
			this.previousKeyHandler();
		},

		downKeyHandler: function () {
			this.nextKeyHandler();
		},

		_lastFocusedDescendant: null,

		focus: function () {
			this._lastFocusedDescendant ? this.navigateTo(this._lastFocusedDescendant) : this.navigateToFirst();
		},

		_keynavDeactivatedHandler: dcl.superCall(function (sup) {
			return function () {
				this._lastFocusedDescendant = this.navigatedDescendant;
				sup.call(this);
			};
		}),

		postRender: function () {
			this.setAttribute("role", "tablist");
			this.setAttribute("aria-multiselectable", "false");
			this.on("delite-remove-child", this._onRemoveChild.bind(this));
		}

	});
});