/* globals define */ ;(function(define){'use strict';define(['require','exports','module','gaia-component','font-fit','gaia-icons'],function(require,exports,module){ /** * Dependencies */ var component = require('gaia-component'); var fontFit = require('font-fit'); /** * Load 'gaia-icons' font-family */ require('gaia-icons'); /** * Simple logger (toggle 0) * * @type {Function} */ var debug = 0 ? console.log.bind(console) : function() {}; /** * Supported action types * * @type {Object} */ const KNOWN_ACTIONS = { menu: 'menu', back: 'back', close: 'close' }; /** * The default title font. * * @type {String} */ const TITLE_FONT = 'italic 300 24px FiraSans'; /** * The padding (start/end) used if * the title needs padding away from * the edge of the header. * * @type {Number} */ const TITLE_PADDING = 10; /** * This is the minimum font size that we can take * when the header title is centered in the window. */ const MINIMUM_FONT_SIZE_CENTERED = 20; /** * This is the minimum font size that we can take * when the header title is not centered in the window. */ const MINIMUM_FONT_SIZE_UNCENTERED = 16; /** * This is the maximum font-size * for the header title. */ const MAXIMUM_FONT_SIZE = 23; /** * Register the element. * * @return {Element} constructor */ module.exports = component.register('gaia-header', { /** * Called when the element is first created. * * Here we create the shadow-root and * inject our template into it. * * @private */ created: function() { debug('created'); this.setupShadowRoot(); // Elements this.els = { actionButton: this.shadowRoot.querySelector('.action-button'), titles: this.getElementsByTagName('h1') }; // Events this.els.actionButton.addEventListener('click', e => this.onActionButtonClick(e)); this.observer = new MutationObserver(this.onMutation.bind(this)); // Properties this.titleEnd = this.getAttribute('title-end'); this.titleStart = this.getAttribute('title-start'); this.noFontFit = this.getAttribute('no-font-fit'); this.notFlush = this.hasAttribute('not-flush'); this.action = this.getAttribute('action'); this.unresolved = {}; this.pending = {}; this._resizeThrottlingId = null; // bind the listener in advance so that we can remove it when detaching. this.onResize = this.onResize.bind(this); }, /** * Called when the element is * attached to the DOM. * * Run fontFit when we have DOM * context and start listening * for DOM mutations. * * We run font-fit on next tick to * avoid reading from the DOM when * it may not be loaded yet. * * @private */ attached: function() { debug('attached'); this.runFontFitSoon(); this.observerStart(); window.addEventListener('resize', this.onResize); }, /** * Called when the element is * detached from the DOM. * * @private */ detached: function() { debug('detached'); window.removeEventListener('resize', this.onResize); this.observerStop(); this.clearPending(); }, /** * Clears pending `.nextTick()`s and requestAnimationFrame's. * * @private */ clearPending: function() { for (var key in this.pending) { this.pending[key].clear(); delete this.pending[key]; } window.cancelAnimationFrame(this._resizeThrottlingId); this._resizeThrottlingId = null; }, /** * Run the font-fit logic and * center the title. * * The styles are calculated synchronously, * but then set asynchronously. This means * this function can be hammered in one turn, * and the title styles will only be written * once, with the latest styles. * * @return {Promise} * @public */ runFontFit: function() { debug('run font-fit'); // Nothing is run if `no-font-fit` attribute // is present. We don't `reject()` as this // isn't technically an error state. if (this.noFontFit) { return Promise.resolve(); } var titles = this.els.titles; var space = this.getTitleSpace(); var styles = [].map.call(titles, el => this.getTitleStyle(el, space)); // Update the title styles using the latest // styles. This function can be called many // times but will only run once in each tick. return this.setTitleStylesSoon(styles); }, /** * Microtask debounced `runFontFit` * * @private */ runFontFitSoon: function() { debug('run font-fit soon'); if (this.pending.runFontFitSoon) { return; } this.pending.runFontFitSoon = this.nextTick(() => { delete this.pending.runFontFitSoon; this.runFontFit(); }); }, /** * Get the style properties required * to fit and position the title text. * * @param {HTMLH1Element} el * @param {Number} space * @return {Object} {fontSize, marginStart, overflowing, id} * @private */ getTitleStyle: function(el, space) { debug('get el style', el, space); var text = el.textContent; var styleId = space.start + text + space.end + '#' + space.value; // Bail when there's no text (or just whitespace) if (!text || !text.trim()) { return debug('exit: no text'); } // If neither the text or the titleSpace // changed, there's no reason to continue. if (getStyleId(el) === styleId) { return debug('exit: no change'); } var marginStart = this.getTitleMarginStart(); var textSpace = space.value - Math.abs(marginStart); var fontFitResult = this.fontFit(text, textSpace, { min: MINIMUM_FONT_SIZE_CENTERED }); var overflowing = fontFitResult.overflowing; var padding = { start: 0, end: 0 }; // If the text is overflowing in the // centered title, we remove centering // to free up space, rerun fontFit to // get a fontSize which fits this space. if (overflowing) { debug('title overflowing'); padding.start = !space.start ? TITLE_PADDING : 0; padding.end = !space.end ? TITLE_PADDING : 0; textSpace = space.value - padding.start - padding.end; fontFitResult = this.fontFit(text, textSpace); marginStart = 0; } return { id: styleId, fontSize: fontFitResult.fontSize, marginStart: marginStart, overflowing: overflowing, padding: padding }; }, /** * Set's styles on the title elements. * * If there is already an unresolved Promise * we return it instead of scheduling another. * This means that the function can be hammered * in the same sync-turn and will only run * once (like a debounce). * * @param {Array} styles * @return {Promise} */ setTitleStylesSoon: function(styles) { debug('set title styles soon', styles); var key = 'setStyleTitlesSoon'; // Always update styles this._titleStyles = styles; // Return existing unresolved // promise, or make a new one if (this.unresolved[key]) { return this.unresolved[key]; } this.unresolved[key] = new Promise((resolve) => { this.pending[key] = this.nextTick(() => { var styles = this._titleStyles; var els = this.els.titles; [].forEach.call(els, (el, i) => { if (!styles[i]) { return debug('exit'); } this.setTitleStyle(el, styles[i]); }); // Clean up delete this._titleStyles; delete this.unresolved[key]; delete this.pending[key]; resolve(); }); }); }, /** * Fit text and center a title between * the buttons before and after. * * Right now, because gaia-header is not * fully rtl-compatible (due to Gaia), * we're using `marginLeft` etc. These * will be changed to `marginStart` etc * when we become fully RTL. * * @param {HTMLH1Element} title * @param {Number} space * @private */ setTitleStyle: function(el, style) { debug('set title style', style); this.observerStop(); el.style.marginLeft = style.marginStart + 'px'; el.style.paddingLeft = style.padding.start + 'px'; el.style.paddingRight = style.padding.end + 'px'; el.style.fontSize = style.fontSize + 'px'; setStyleId(el, style.id); this.observerStart(); }, /** * Run font-fit on a title with * the given amount of content space. * @param {String} text * @param {Number} space * @param {Object} optional {[min]} * @return {Object} {fontSize, textWidth} * @private */ fontFit: function(text, space, opts = {}) { debug('font fit:', text, space, opts); var fontFitArgs = { font: TITLE_FONT, min: opts.min || MINIMUM_FONT_SIZE_UNCENTERED, max: MAXIMUM_FONT_SIZE, text: text, space: space }; return fontFit(fontFitArgs); }, /** * Start the observer listening * for DOM mutations. * Start the listener for 'resize' event. * * @private */ observerStart: function() { if (this.observing) { return; } this.observer.observe(this, { childList: true, attributes: true, subtree: true }); this.observing = true; debug('observer started'); }, /** * Stop the observer listening * for DOM mutations. * * @private */ observerStop: function() { if (!this.observing) { return; } this.observer.disconnect(); this.observing = false; debug('observer stopped'); }, /** * Handle 'resize' events. * @param {Event} The DOM Event that's being handled. * * @private */ onResize: function(e) { debug('onResize', this._resizeThrottlingId); if (this._resizeThrottlingId !== null) { return; } /* Resize events can arrive at a very high rate, so we're trying to * reasonably throttle these events. */ this._resizeThrottlingId = window.requestAnimationFrame(() => { this._resizeThrottlingId = null; this.runFontFitSoon(); }); }, /** * When the components DOM changes we * call `.runFontFit()` (sync). * * If the `textContent` is changed in a * mutation observer just after attaching, * we end up running twice. * * If there is a pending async .runFontFit(), * then we don't want to run it now. * * @param {Array} mutations * @private */ onMutation: function(mutations) { debug('on mutation', mutations); if (!this.pending.runFontFitSoon) { this.runFontFit(); } }, /** * Get the title width. * * Returns the space available for

. * * @return {Number} * @private */ getTitleSpace: function() { var start = this.titleStart; var end = this.titleEnd; var space = this.getWidth() - start - end; var result = { value: space, start: start, end: end }; debug('get title space', result); return result; }, /** * Get the width of the component. * * For performance reasons we make the * assumption that the width is the same * as `window.innerWidth` unless the * `not-flush` attribute is used. * * @return {Number} * @private */ getWidth: function() { var value = this.notFlush ? this.clientWidth : window.innerWidth; debug('get width', value); return value; }, /** * Triggers the 'action' button * (used in Gaia integration testing). * * @public */ triggerAction: function() { if (this.action) { this.els.actionButton.click(); } }, /** * Handle clicks on the action button. * * Fired pending to allow the 'click' event * to finish its event path before * dispatching the 'action' event. * * @param {Event} e * @private */ onActionButtonClick: function() { debug('action button click'); var config = { detail: { type: this.action } }; var e = new CustomEvent('action', config); setTimeout(() => this.dispatchEvent(e)); }, /** * Get the margin-start value required * to center the title between * surrounding buttons. * * @param {Object} title * @return {Object} * @private */ getTitleMarginStart: function() { var start = this.titleStart; var end = this.titleEnd; var marginStart = end - start; debug('get title margin start', marginStart); return marginStart; }, /** * Get all the buttons ( & `, // Test hook nextTick: nextTick }); /** * Utils */ /** * Determines whether passed element * contributes to the layout in gaia-header. * * @param {Element} el * @return {Boolean} */ function contributesToLayout(el) { return el.tagName !== 'STYLE'; } /** * Set a 'style id' property that * can be retrieved later. * * Used to determine whether a title's * `style` needs to be updated or not. * * @param {Element} el * @param {String} id */ function setStyleId(el, id) { el._styleId = id; } /** * Get a 'style id' property. * * Used to determine whether a title's * `style` needs to be updated or not. * * @param {Element} el * @param {String} id */ function getStyleId(el) { return el._styleId; } /** * Calls callback at next 'microtask'. * Returns an object that has * a `.clear()` method. * * @param {Function} fn * @return {Object} { clear } */ function nextTick(fn) { var cleared; Promise.resolve().then(() => { if (!cleared) { fn(); } }); return { clear: function() { cleared = true; }}; } });})(typeof define=='function'&&define.amd?define :(function(n,w){'use strict';return typeof module=='object'?function(c){ c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){ return w[n];},m.exports,m);w[n]=m.exports;};})('gaia-header',this));