From 18be342e59f6ed60f9489e164c1746de7baa21d8 Mon Sep 17 00:00:00 2001 From: Will Ernest <34519388+williamernest@users.noreply.github.com> Date: Fri, 6 Apr 2018 14:54:41 -0700 Subject: [PATCH] feat(top-app-bar): add default scroll behavior (#2417) BREAKING CHANGE: New adapter methods for setting the top app bar position and adding resize event handlers that must be implemented. --- demos/top-app-bar.html | 22 +- packages/mdc-top-app-bar/_mixins.scss | 4 + packages/mdc-top-app-bar/adapter.js | 19 ++ packages/mdc-top-app-bar/constants.js | 26 +- packages/mdc-top-app-bar/foundation.js | 19 +- packages/mdc-top-app-bar/index.js | 16 +- packages/mdc-top-app-bar/mdc-top-app-bar.scss | 23 +- packages/mdc-top-app-bar/short/foundation.js | 9 +- .../mdc-top-app-bar/standard/foundation.js | 214 +++++++++++++++ test/unit/mdc-top-app-bar/foundation.test.js | 24 +- .../mdc-top-app-bar/mdc-top-app-bar.test.js | 36 +++ .../standard.foundation.test.js | 246 ++++++++++++++++++ test/unit/mdc-top-app-bar/util.test.js | 0 13 files changed, 620 insertions(+), 38 deletions(-) create mode 100644 packages/mdc-top-app-bar/standard/foundation.js create mode 100644 test/unit/mdc-top-app-bar/standard.foundation.test.js create mode 100644 test/unit/mdc-top-app-bar/util.test.js diff --git a/demos/top-app-bar.html b/demos/top-app-bar.html index d19f8c6f663..daef9828855 100644 --- a/demos/top-app-bar.html +++ b/demos/top-app-bar.html @@ -80,7 +80,7 @@
-
+

Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante. Donec eu libero sit amet quam egestas semper. Aenean ultricies mi vitae est.

@@ -208,6 +208,7 @@

Demo Controls

var rightItemEl = document.getElementById('right-item'); var rightSection = document.getElementById('iconSection'); var drawerEl = document.querySelector('.mdc-drawer'); + var contentMainEl = document.getElementById('content-main'); var drawer = new mdc.drawer.MDCTemporaryDrawer(drawerEl); var appBar = mdc.topAppBar.MDCTopAppBar.attachTo(appBarEl); @@ -247,6 +248,14 @@

Demo Controls

// Top App Bar Specific Options denseCheckbox.addEventListener('change', function() { appBarEl.classList[this.checked ? 'add' : 'remove']('mdc-top-app-bar--dense'); + contentMainEl.classList[this.checked ? 'add' : 'remove']('mdc-top-app-bar--dense-fixed-adjust'); + if (prominentCheckbox.checked) { + contentMainEl.classList[this.checked ? 'add' : 'remove']('mdc-top-app-bar--dense-prominent-fixed-adjust'); + } + + + appBar.destroy(); + appBar = mdc.topAppBar.MDCTopAppBar.attachTo(appBarEl); shortCheckbox.disabled = this.checked || prominentCheckbox.checked || fixedCheckbox.checked; }); @@ -264,6 +273,14 @@

Demo Controls

prominentCheckbox.addEventListener('change', function() { appBarEl.classList[this.checked ? 'add' : 'remove']('mdc-top-app-bar--prominent'); + contentMainEl.classList[this.checked ? 'add' : 'remove']('mdc-top-app-bar--prominent-fixed-adjust'); + + if (denseCheckbox.checked) { + contentMainEl.classList[this.checked ? 'add' : 'remove']('mdc-top-app-bar--dense-prominent-fixed-adjust'); + } + + appBar.destroy(); + appBar = mdc.topAppBar.MDCTopAppBar.attachTo(appBarEl); shortCheckbox.disabled = this.checked || denseCheckbox.checked || fixedCheckbox.checked; }); @@ -272,6 +289,7 @@

Demo Controls

shortCheckbox.addEventListener('change', function() { appBarEl.classList[this.checked ? 'add' : 'remove']('mdc-top-app-bar--short'); appBarEl.classList.remove('mdc-top-app-bar--short-has-action-item'); + contentMainEl.classList[this.checked ? 'add' : 'remove']('mdc-top-app-bar--short-fixed-adjust'); appBar.destroy(); appBar = mdc.topAppBar.MDCTopAppBar.attachTo(appBarEl); @@ -294,8 +312,6 @@

Demo Controls

appBar.destroy(); appBar = mdc.topAppBar.MDCTopAppBar.attachTo(appBarEl); }); - - }); diff --git a/packages/mdc-top-app-bar/_mixins.scss b/packages/mdc-top-app-bar/_mixins.scss index 20f8a1cee7b..3919ac5ee3d 100644 --- a/packages/mdc-top-app-bar/_mixins.scss +++ b/packages/mdc-top-app-bar/_mixins.scss @@ -76,6 +76,10 @@ padding-bottom: $mdc-top-app-bar-prominent-mobile-title-bottom-padding; } } + + .mdc-top-app-bar--fixed-adjust { + margin-top: $mdc-top-app-bar-mobile-row-height; + } } } diff --git a/packages/mdc-top-app-bar/adapter.js b/packages/mdc-top-app-bar/adapter.js index 6b63e016613..bddb772d631 100644 --- a/packages/mdc-top-app-bar/adapter.js +++ b/packages/mdc-top-app-bar/adapter.js @@ -47,6 +47,19 @@ class MDCTopAppBarAdapter { */ hasClass(className) {} + /** + * Sets the specified inline style property on the root Element to the given value. + * @param {string} property + * @param {string} value + */ + setStyle(property, value) {} + + /** + * Gets the height of the top app bar. + * @return {number} + */ + getTopAppBarHeight() {} + /** * Registers an event handler on the navigation icon element for a given event. * @param {string} type @@ -72,6 +85,12 @@ class MDCTopAppBarAdapter { /** @param {function(!Event)} handler */ deregisterScrollHandler(handler) {} + /** @param {function(!Event)} handler */ + registerResizeHandler(handler) {} + + /** @param {function(!Event)} handler */ + deregisterResizeHandler(handler) {} + /** @return {number} */ getViewportScrollY() {} diff --git a/packages/mdc-top-app-bar/constants.js b/packages/mdc-top-app-bar/constants.js index 1438fdd2e0b..d1c4985f361 100644 --- a/packages/mdc-top-app-bar/constants.js +++ b/packages/mdc-top-app-bar/constants.js @@ -15,15 +15,6 @@ * limitations under the License. */ -/** @enum {string} */ -const strings = { - NAVIGATION_EVENT: 'MDCTopAppBar:nav', - ROOT_SELECTOR: '.mdc-top-app-bar', - TITLE_SELECTOR: '.mdc-top-app-bar__title', - NAVIGATION_ICON_SELECTOR: '.mdc-top-app-bar__navigation-icon', - ACTION_ITEM_SELECTOR: '.mdc-top-app-bar__action-item', -}; - /** @enum {string} */ const cssClasses = { FIXED_CLASS: 'mdc-top-app-bar--fixed', @@ -33,4 +24,19 @@ const cssClasses = { SHORT_COLLAPSED_CLASS: 'mdc-top-app-bar--short-collapsed', }; -export {strings, cssClasses}; +/** @enum {number} */ +const numbers = { + DEBOUNCE_THROTTLE_RESIZE_TIME_MS: 100, + MAX_TOP_APP_BAR_HEIGHT: 128, +}; + +/** @enum {string} */ +const strings = { + ACTION_ITEM_SELECTOR: '.mdc-top-app-bar__action-item', + NAVIGATION_EVENT: 'MDCTopAppBar:nav', + NAVIGATION_ICON_SELECTOR: '.mdc-top-app-bar__navigation-icon', + ROOT_SELECTOR: '.mdc-top-app-bar', + TITLE_SELECTOR: '.mdc-top-app-bar__title', +}; + +export {strings, cssClasses, numbers}; diff --git a/packages/mdc-top-app-bar/foundation.js b/packages/mdc-top-app-bar/foundation.js index 07f93ab0f68..c37e9c80248 100644 --- a/packages/mdc-top-app-bar/foundation.js +++ b/packages/mdc-top-app-bar/foundation.js @@ -15,14 +15,14 @@ * limitations under the License. */ -import {strings, cssClasses} from './constants'; +import {strings, cssClasses, numbers} from './constants'; import MDCTopAppBarAdapter from './adapter'; import MDCFoundation from '@material/base/foundation'; /** * @extends {MDCFoundation} */ -class MDCTopAppBarFoundation extends MDCFoundation { +class MDCTopAppBarBaseFoundation extends MDCFoundation { /** @return enum {string} */ static get strings() { return strings; @@ -33,6 +33,11 @@ class MDCTopAppBarFoundation extends MDCFoundation { return cssClasses; } + /** @return enum {number} */ + static get numbers() { + return numbers; + } + /** * {@see MDCTopAppBarAdapter} for typing information on parameters and return * types. @@ -43,11 +48,15 @@ class MDCTopAppBarFoundation extends MDCFoundation { hasClass: (/* className: string */) => {}, addClass: (/* className: string */) => {}, removeClass: (/* className: string */) => {}, + setStyle: (/* property: string, value: string */) => {}, + getTopAppBarHeight: () => {}, registerNavigationIconInteractionHandler: (/* type: string, handler: EventListener */) => {}, deregisterNavigationIconInteractionHandler: (/* type: string, handler: EventListener */) => {}, notifyNavigationIconClicked: () => {}, registerScrollHandler: (/* handler: EventListener */) => {}, deregisterScrollHandler: (/* handler: EventListener */) => {}, + registerResizeHandler: (/* handler: EventListener */) => {}, + deregisterResizeHandler: (/* handler: EventListener */) => {}, getViewportScrollY: () => /* number */ 0, getTotalActionItems: () => /* number */ 0, }); @@ -56,8 +65,8 @@ class MDCTopAppBarFoundation extends MDCFoundation { /** * @param {!MDCTopAppBarAdapter} adapter */ - constructor(adapter) { - super(Object.assign(MDCTopAppBarFoundation.defaultAdapter, adapter)); + constructor(/** @type {!MDCTopAppBarAdapter} */ adapter) { + super(Object.assign(MDCTopAppBarBaseFoundation.defaultAdapter, adapter)); this.navClickHandler_ = () => this.adapter_.notifyNavigationIconClicked(); } @@ -71,4 +80,4 @@ class MDCTopAppBarFoundation extends MDCFoundation { } } -export default MDCTopAppBarFoundation; +export default MDCTopAppBarBaseFoundation; diff --git a/packages/mdc-top-app-bar/index.js b/packages/mdc-top-app-bar/index.js index 8aa9a7fa492..395ab929e0b 100644 --- a/packages/mdc-top-app-bar/index.js +++ b/packages/mdc-top-app-bar/index.js @@ -16,15 +16,16 @@ */ import MDCTopAppBarAdapter from './adapter'; -import MDCTopAppBarFoundation from './foundation'; import MDCComponent from '@material/base/component'; import {MDCRipple} from '@material/ripple/index'; import {cssClasses, strings} from './constants'; +import MDCTopAppBarBaseFoundation from './foundation'; import MDCFixedTopAppBarFoundation from './fixed/foundation'; import MDCShortTopAppBarFoundation from './short/foundation'; +import MDCTopAppBarFoundation from './standard/foundation'; /** - * @extends {MDCComponent} + * @extends {MDCComponent} * @final */ class MDCTopAppBar extends MDCComponent { @@ -68,7 +69,7 @@ class MDCTopAppBar extends MDCComponent { } /** - * @return {!MDCTopAppBarFoundation} + * @return {!MDCTopAppBarBaseFoundation} */ getDefaultFoundation() { /** @type {!MDCTopAppBarAdapter} */ @@ -76,6 +77,8 @@ class MDCTopAppBar extends MDCComponent { hasClass: (className) => this.root_.classList.contains(className), addClass: (className) => this.root_.classList.add(className), removeClass: (className) => this.root_.classList.remove(className), + setStyle: (property, value) => this.root_.style.setProperty(property, value), + getTopAppBarHeight: () => this.root_.clientHeight, registerNavigationIconInteractionHandler: (evtType, handler) => { if (this.navIcon_) { this.navIcon_.addEventListener(evtType, handler); @@ -91,12 +94,15 @@ class MDCTopAppBar extends MDCComponent { }, registerScrollHandler: (handler) => window.addEventListener('scroll', handler), deregisterScrollHandler: (handler) => window.removeEventListener('scroll', handler), + registerResizeHandler: (handler) => window.addEventListener('resize', handler), + deregisterResizeHandler: (handler) => window.removeEventListener('resize', handler), getViewportScrollY: () => window.pageYOffset, getTotalActionItems: () => this.root_.querySelectorAll(strings.ACTION_ITEM_SELECTOR).length, }) ); + /** @type {!MDCTopAppBarBaseFoundation} */ let foundation; if (this.root_.classList.contains(cssClasses.SHORT_CLASS)) { foundation = new MDCShortTopAppBarFoundation(adapter); @@ -110,4 +116,6 @@ class MDCTopAppBar extends MDCComponent { } } -export {MDCTopAppBar, MDCTopAppBarFoundation, MDCFixedTopAppBarFoundation, MDCShortTopAppBarFoundation}; +export {MDCTopAppBar, MDCTopAppBarBaseFoundation, + MDCTopAppBarFoundation, MDCFixedTopAppBarFoundation, + MDCShortTopAppBarFoundation}; diff --git a/packages/mdc-top-app-bar/mdc-top-app-bar.scss b/packages/mdc-top-app-bar/mdc-top-app-bar.scss index 1089b73d472..fe600876eff 100644 --- a/packages/mdc-top-app-bar/mdc-top-app-bar.scss +++ b/packages/mdc-top-app-bar/mdc-top-app-bar.scss @@ -27,7 +27,7 @@ @include mdc-top-app-bar-icon-ink-color(text-primary-on-primary); display: flex; - position: relative; + position: fixed; flex-direction: column; justify-content: space-between; box-sizing: border-box; @@ -159,6 +159,7 @@ } } +// stylelint-disable-next-line plugin/selector-bem-pattern .mdc-top-app-bar--fixed { position: fixed; transition: box-shadow 200ms linear; @@ -189,5 +190,25 @@ } // stylelint-enable plugin/selector-bem-pattern +.mdc-top-app-bar--fixed-adjust { + margin-top: $mdc-top-app-bar-row-height; +} + +.mdc-top-app-bar--dense-fixed-adjust { + margin-top: $mdc-top-app-bar-dense-row-height; +} + +.mdc-top-app-bar--short-fixed-adjust { + margin-top: $mdc-top-app-bar-mobile-row-height; +} + +.mdc-top-app-bar--prominent-fixed-adjust { + margin-top: $mdc-top-app-bar-prominent-row-height; +} + +.mdc-top-app-bar--dense-prominent-fixed-adjust { + margin-top: $mdc-top-app-bar-dense-row-height * 2; +} + // Mobile Styles @include mdc-top-app-bar-mobile-breakpoint_; diff --git a/packages/mdc-top-app-bar/short/foundation.js b/packages/mdc-top-app-bar/short/foundation.js index cb6d7c76b3e..0b0ed89af8e 100644 --- a/packages/mdc-top-app-bar/short/foundation.js +++ b/packages/mdc-top-app-bar/short/foundation.js @@ -15,15 +15,15 @@ * limitations under the License. */ -import {cssClasses} from '../constants'; import MDCTopAppBarAdapter from '../adapter'; -import MDCTopAppBarFoundation from '../foundation'; +import MDCTopAppBarBaseFoundation from '../foundation'; +import {cssClasses} from '../constants'; /** - * @extends {MDCTopAppBarFoundation} + * @extends {MDCTopAppBarBaseFoundation} * @final */ -class MDCShortTopAppBarFoundation extends MDCTopAppBarFoundation { +class MDCShortTopAppBarFoundation extends MDCTopAppBarBaseFoundation { /** * @param {!MDCTopAppBarAdapter} adapter */ @@ -58,6 +58,7 @@ class MDCShortTopAppBarFoundation extends MDCTopAppBarFoundation { /** * Scroll handler for applying/removing the collapsed modifier class * on the short top app bar. + * @private */ shortAppBarScrollHandler_() { const currentScroll = this.adapter_.getViewportScrollY(); diff --git a/packages/mdc-top-app-bar/standard/foundation.js b/packages/mdc-top-app-bar/standard/foundation.js new file mode 100644 index 00000000000..5156d25de78 --- /dev/null +++ b/packages/mdc-top-app-bar/standard/foundation.js @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MDCTopAppBarAdapter from '../adapter'; +import MDCTopAppBarBaseFoundation from '../foundation'; +import {numbers} from '../constants'; + +const INITIAL_VALUE = 0; +/** + * @extends {MDCTopAppBarBaseFoundation} + * @final + */ +class MDCTopAppBarFoundation extends MDCTopAppBarBaseFoundation { + /** + * @param {!MDCTopAppBarAdapter} adapter + */ + constructor(adapter) { + super(adapter); + /** + * Used for diffs of current scroll position vs previous scroll position + * @private {number} + */ + this.lastScrollPosition_ = this.adapter_.getViewportScrollY(); + + /** + * Used to verify when the top app bar is completely showing or completely hidden + * @private {number} + */ + this.topAppBarHeight_ = this.adapter_.getTopAppBarHeight(); + + /** + * wasDocked_ is used to indicate if the top app bar was docked in the previous + * scroll handler iteration. + * @private {boolean} + */ + this.wasDocked_ = true; + + /** + * isDockedShowing_ is used to indicate if the top app bar is docked in the fully + * shown position. + * @private {boolean} + */ + this.isDockedShowing_ = true; + + /** + * Variable for current scroll position of the top app bar + * @private {number} + */ + this.currentAppBarOffsetTop_ = 0; + + /** + * Used to prevent the top app bar from being scrolled out of view during resize events + * @private {boolean} */ + this.isCurrentlyBeingResized_ = false; + + /** + * The timeout that's used to throttle the resize events + * @private {number} + */ + this.resizeThrottleId_ = INITIAL_VALUE; + + /** + * The timeout that's used to debounce toggling the isCurrentlyBeingResized_ variable after a resize + * @private {number} + */ + this.resizeDebounceId_ = INITIAL_VALUE; + + this.scrollHandler_ = () => this.topAppBarScrollHandler_(); + this.resizeHandler_ = () => this.topAppBarResizeHandler_(); + } + + init() { + super.init(); + this.adapter_.registerScrollHandler(this.scrollHandler_); + this.adapter_.registerResizeHandler(this.resizeHandler_); + } + + destroy() { + super.destroy(); + this.adapter_.deregisterScrollHandler(this.scrollHandler_); + this.adapter_.deregisterResizeHandler(this.resizeHandler_); + this.adapter_.setStyle('top', ''); + } + + /** + * Function to determine if the DOM needs to update. + * @return {boolean} + * @private + */ + checkForUpdate_() { + const offscreenBoundaryTop = -this.topAppBarHeight_; + const hasAnyPixelsOffscreen = this.currentAppBarOffsetTop_ < 0; + const hasAnyPixelsOnscreen = this.currentAppBarOffsetTop_ > offscreenBoundaryTop; + const partiallyShowing = hasAnyPixelsOffscreen && hasAnyPixelsOnscreen; + + // If it's partially showing, it can't be docked. + if (partiallyShowing) { + this.wasDocked_ = false; + } else { + // Not previously docked and not partially showing, it's now docked. + if (!this.wasDocked_) { + this.wasDocked_ = true; + return true; + } else if (this.isDockedShowing_ !== hasAnyPixelsOnscreen) { + this.isDockedShowing_ = hasAnyPixelsOnscreen; + return true; + } + } + + return partiallyShowing; + } + + /** + * Function to move the top app bar if needed. + * @private + */ + moveTopAppBar_() { + if (this.checkForUpdate_()) { + // Once the top app bar is fully hidden we use the max potential top app bar height as our offset + // so the top app bar doesn't show if the window resizes and the new height > the old height. + let offset = this.currentAppBarOffsetTop_; + if (Math.abs(offset) >= this.topAppBarHeight_) { + offset = -numbers.MAX_TOP_APP_BAR_HEIGHT; + } + + this.adapter_.setStyle('top', offset + 'px'); + } + } + + /** + * Scroll handler for the default scroll behavior of the top app bar. + * @private + */ + topAppBarScrollHandler_() { + const currentScrollPosition = Math.max(this.adapter_.getViewportScrollY(), 0); + const diff = currentScrollPosition - this.lastScrollPosition_; + this.lastScrollPosition_ = currentScrollPosition; + + // If the window is being resized the lastScrollPosition_ needs to be updated but the + // current scroll of the top app bar should stay in the same position. + if (!this.isCurrentlyBeingResized_) { + this.currentAppBarOffsetTop_ -= diff; + + if (this.currentAppBarOffsetTop_ > 0) { + this.currentAppBarOffsetTop_ = 0; + } else if (Math.abs(this.currentAppBarOffsetTop_) > this.topAppBarHeight_) { + this.currentAppBarOffsetTop_ = -this.topAppBarHeight_; + } + + this.moveTopAppBar_(); + } + } + + /** + * Top app bar resize handler that throttle/debounce functions that execute updates. + * @private + */ + topAppBarResizeHandler_() { + // Throttle resize events 10 p/s + if (!this.resizeThrottleId_) { + this.resizeThrottleId_ = setTimeout(() => { + this.resizeThrottleId_ = INITIAL_VALUE; + this.throttledResizeHandler_(); + }, numbers.DEBOUNCE_THROTTLE_RESIZE_TIME_MS); + } + + this.isCurrentlyBeingResized_ = true; + + if (this.resizeDebounceId_) { + clearTimeout(this.resizeDebounceId_); + } + + this.resizeDebounceId_ = setTimeout(() => { + this.topAppBarScrollHandler_(); + this.isCurrentlyBeingResized_ = false; + this.resizeDebounceId_ = INITIAL_VALUE; + }, numbers.DEBOUNCE_THROTTLE_RESIZE_TIME_MS); + } + + /** + * Throttled function that updates the top app bar scrolled values if the + * top app bar height changes. + * @private + */ + throttledResizeHandler_() { + const currentHeight = this.adapter_.getTopAppBarHeight(); + if (this.topAppBarHeight_ !== currentHeight) { + this.wasDocked_ = false; + + // Since the top app bar has a different height depending on the screen width, this + // will ensure that the top app bar remains in the correct location if + // completely hidden and a resize makes the top app bar a different height. + this.currentAppBarOffsetTop_ -= this.topAppBarHeight_ - currentHeight; + this.topAppBarHeight_ = currentHeight; + } + this.topAppBarScrollHandler_(); + } +} + +export default MDCTopAppBarFoundation; diff --git a/test/unit/mdc-top-app-bar/foundation.test.js b/test/unit/mdc-top-app-bar/foundation.test.js index 4a36a688bc5..fe949e478b2 100644 --- a/test/unit/mdc-top-app-bar/foundation.test.js +++ b/test/unit/mdc-top-app-bar/foundation.test.js @@ -1,4 +1,5 @@ /** + * @license * Copyright 2016 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -19,33 +20,34 @@ import td from 'testdouble'; import {captureHandlers} from '../helpers/foundation'; import {verifyDefaultAdapter} from '../helpers/foundation'; -import MDCTopAppBarFoundation from '../../../packages/mdc-top-app-bar/foundation'; +import MDCTopAppBarBaseFoundation from '../../../packages/mdc-top-app-bar/foundation'; import {strings, cssClasses} from '../../../packages/mdc-top-app-bar/constants'; -suite('MDCTopAppBarFoundation'); +suite('MDCTopAppBarBaseFoundation'); test('exports strings', () => { - assert.isTrue('strings' in MDCTopAppBarFoundation); - assert.deepEqual(MDCTopAppBarFoundation.strings, strings); + assert.isTrue('strings' in MDCTopAppBarBaseFoundation); + assert.deepEqual(MDCTopAppBarBaseFoundation.strings, strings); }); test('exports cssClasses', () => { - assert.isTrue('cssClasses' in MDCTopAppBarFoundation); - assert.deepEqual(MDCTopAppBarFoundation.cssClasses, cssClasses); + assert.isTrue('cssClasses' in MDCTopAppBarBaseFoundation); + assert.deepEqual(MDCTopAppBarBaseFoundation.cssClasses, cssClasses); }); test('defaultAdapter returns a complete adapter implementation', () => { - verifyDefaultAdapter(MDCTopAppBarFoundation, [ - 'hasClass', 'addClass', 'removeClass', 'registerNavigationIconInteractionHandler', + verifyDefaultAdapter(MDCTopAppBarBaseFoundation, [ + 'hasClass', 'addClass', 'removeClass', 'setStyle', 'getTopAppBarHeight', 'registerNavigationIconInteractionHandler', 'deregisterNavigationIconInteractionHandler', 'notifyNavigationIconClicked', 'registerScrollHandler', - 'deregisterScrollHandler', 'getViewportScrollY', 'getTotalActionItems', + 'deregisterScrollHandler', 'registerResizeHandler', 'deregisterResizeHandler', 'getViewportScrollY', + 'getTotalActionItems', ]); }); const setupTest = () => { - const mockAdapter = td.object(MDCTopAppBarFoundation.defaultAdapter); + const mockAdapter = td.object(MDCTopAppBarBaseFoundation.defaultAdapter); - const foundation = new MDCTopAppBarFoundation(mockAdapter); + const foundation = new MDCTopAppBarBaseFoundation(mockAdapter); return {foundation, mockAdapter}; }; diff --git a/test/unit/mdc-top-app-bar/mdc-top-app-bar.test.js b/test/unit/mdc-top-app-bar/mdc-top-app-bar.test.js index b0d218d93cb..b188ca75e70 100644 --- a/test/unit/mdc-top-app-bar/mdc-top-app-bar.test.js +++ b/test/unit/mdc-top-app-bar/mdc-top-app-bar.test.js @@ -1,4 +1,5 @@ /** + * @license * Copyright 2017 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -155,6 +156,13 @@ test('adapter#removeClass removes a class from the root element', () => { assert.isFalse(root.classList.contains('foo')); }); +test('adapter#setStyle sets a style attribute on the root element', () => { + const {root, component} = setupTest(); + assert.isFalse(root.style.getPropertyValue('top') === '1px'); + component.getDefaultFoundation().adapter_.setStyle('top', '1px'); + assert.isTrue(root.style.getPropertyValue('top') === '1px'); +}); + test('registerNavigationIconInteractionHandler does not add a handler to the nav icon if the nav icon is null', () => { const {component} = setupTest(true); const handler = td.func('eventHandler'); @@ -221,6 +229,34 @@ test('#adapter.deregisterScrollHandler removes a scroll handler from the window } }); +test('#adapter.registerResizeHandler adds a resize handler to the window', () => { + const {component} = setupTest(); + const handler = td.func('resizeHandler'); + component.getDefaultFoundation().adapter_.registerResizeHandler(handler); + + domEvents.emit(window, 'resize'); + try { + td.verify(handler(td.matchers.anything())); + } finally { + // Just to be safe + window.removeEventListener('resize', handler); + } +}); + +test('#adapter.deregisterResizeHandler removes a resize handler from the window', () => { + const {component} = setupTest(); + const handler = td.func('resizeHandler'); + window.addEventListener('resize', handler); + component.getDefaultFoundation().adapter_.deregisterResizeHandler(handler); + domEvents.emit(window, 'resize'); + try { + td.verify(handler(td.matchers.anything()), {times: 0}); + } finally { + // Just to be safe + window.removeEventListener('resize', handler); + } +}); + test('adapter#getViewportScrollY returns scroll distance', () => { const {component} = setupTest(); assert.equal(component.getDefaultFoundation().adapter_.getViewportScrollY(), window.pageYOffset); diff --git a/test/unit/mdc-top-app-bar/standard.foundation.test.js b/test/unit/mdc-top-app-bar/standard.foundation.test.js new file mode 100644 index 00000000000..3e34af8bf1f --- /dev/null +++ b/test/unit/mdc-top-app-bar/standard.foundation.test.js @@ -0,0 +1,246 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {assert} from 'chai'; +import td from 'testdouble'; + +import MDCTopAppBarFoundation from '../../../packages/mdc-top-app-bar/standard/foundation'; +import {numbers} from '../../../packages/mdc-top-app-bar/constants'; +import {createMockRaf} from '../helpers/raf'; +import lolex from 'lolex'; + +suite('MDCTopAppBarFoundation'); + +const setupTest = () => { + const mockAdapter = td.object(MDCTopAppBarFoundation.defaultAdapter); + td.when(mockAdapter.getTopAppBarHeight()).thenReturn(64); + td.when(mockAdapter.getViewportScrollY()).thenReturn(0); + + const foundation = new MDCTopAppBarFoundation(mockAdapter); + + return {foundation, mockAdapter}; +}; + +const createMockHandlers = (foundation, mockAdapter, mockRaf) => { + let scrollHandler; + let resizeHandler; + td.when(mockAdapter.registerScrollHandler(td.matchers.isA(Function))).thenDo((fn) => { + scrollHandler = fn; + }); + td.when(mockAdapter.registerResizeHandler(td.matchers.isA(Function))).thenDo((fn) => { + resizeHandler = fn; + }); + + foundation.init(); + mockRaf.flush(); + td.reset(); + return {scrollHandler, resizeHandler}; +}; + +test('top app bar: listeners is registered on init', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + td.verify(mockAdapter.registerScrollHandler(td.matchers.isA(Function)), {times: 1}); + td.verify(mockAdapter.registerResizeHandler(td.matchers.isA(Function)), {times: 1}); +}); + +test('listeners removed on destroy', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.hasClass(MDCTopAppBarFoundation.cssClasses.SHORT_CLASS)).thenReturn(true); + foundation.init(); + foundation.destroy(); + td.verify(mockAdapter.deregisterScrollHandler(td.matchers.isA(Function)), {times: 1}); + td.verify(mockAdapter.deregisterResizeHandler(td.matchers.isA(Function)), {times: 1}); +}); + +test('top app bar scroll: throttledResizeHandler_ updates topAppBarHeight_ if the top app bar height changes', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + assert.isTrue(foundation.topAppBarHeight_ === 64); + td.when(mockAdapter.getTopAppBarHeight()).thenReturn(56); + foundation.throttledResizeHandler_(); + assert.isTrue(foundation.topAppBarHeight_ === 56); +}); + +test('top app bar scroll: throttledResizeHandler_ does not update topAppBarHeight_ if height is the same', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + assert.isTrue(foundation.topAppBarHeight_ === 64); + td.when(mockAdapter.getTopAppBarHeight()).thenReturn(64); + foundation.throttledResizeHandler_(); + assert.isTrue(foundation.topAppBarHeight_ === 64); +}); + +test('top app bar : moveTopAppBar_ update required transition from fully shown to 1px scrolled', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + foundation.currentAppBarOffsetTop_ = -1; // Indicates 1px scrolled up + foundation.checkForUpdate_ = () => true; + foundation.moveTopAppBar_(); + td.verify(mockAdapter.setStyle('top', '-1px'), {times: 1}); +}); + +test('top app bar : moveTopAppBar_ update required transition from 1px shown to fullyHidden ', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + foundation.currentAppBarOffsetTop_ = -64; // Indicates 64px scrolled + foundation.checkForUpdate_ = () => true; + foundation.moveTopAppBar_(); + td.verify(mockAdapter.setStyle('top', '-' + numbers.MAX_TOP_APP_BAR_HEIGHT + 'px')); +}); + +test('top app bar : moveTopAppBar_ update is not required results in no top app bar style change', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + foundation.currentAppBarOffsetTop_ = 0; + foundation.checkForUpdate_ = () => false; + foundation.moveTopAppBar_(); + td.verify(mockAdapter.setStyle('top', td.matchers.anything()), {times: 0}); +}); + +test('top app bar : checkForUpdate_ returns true if top app bar is not docked', () => { + const {foundation} = setupTest(); + foundation.init(); + foundation.currentAppBarOffsetTop_ = -1; + foundation.wasDocked_ = false; + assert.isTrue(foundation.checkForUpdate_()); +}); + +test('top app bar : checkForUpdate_ updates wasDocked_ to true if top app bar becomes docked', () => { + const {foundation} = setupTest(); + foundation.init(); + foundation.currentAppBarOffsetTop_ = 0; + foundation.wasDocked_ = false; + assert.isTrue(foundation.checkForUpdate_()); + assert.isTrue(foundation.wasDocked_); +}); + +test('top app bar : checkForUpdate_ returns false if top app bar is docked and fullyShown', () => { + const {foundation} = setupTest(); + foundation.init(); + foundation.currentAppBarOffsetTop_ = 0; + foundation.wasDocked_ = true; + assert.isFalse(foundation.checkForUpdate_()); + assert.isTrue(foundation.wasDocked_); +}); + +test('top app bar : checkForUpdate_ returns false if top app bar is docked and fullyHidden', () => { + const {foundation} = setupTest(); + foundation.init(); + foundation.currentAppBarOffsetTop_ = -64; + foundation.wasDocked_ = true; + foundation.isDockedShowing_ = false; + assert.isFalse(foundation.checkForUpdate_()); + assert.isTrue(foundation.wasDocked_); +}); + +test('top app bar : checkForUpdate_ returns true if top app bar is docked but not fullyShown/fullyHidden', () => { + const {foundation} = setupTest(); + foundation.init(); + foundation.currentAppBarOffsetTop_ = -63; + foundation.wasDocked_ = true; + assert.isTrue(foundation.checkForUpdate_()); + assert.isFalse(foundation.wasDocked_); +}); + +test('top app bar : topAppBarScrollHandler_ does not update currentAppBarOffsetTop_ if ' + + 'isCurrentlyBeingResized_ is true', () => { + const {foundation} = setupTest(); + foundation.init(); + foundation.isCurrentlyBeingResized_ = true; + foundation.topAppBarScrollHandler_(); + assert.isTrue(foundation.currentAppBarOffsetTop_ === 0); +}); + +test('top app bar : topAppBarScrollHandler_ subtracts the currentAppBarOffsetTop_ amount scrolled', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + td.when(mockAdapter.getViewportScrollY()).thenReturn(1); + foundation.topAppBarScrollHandler_(); + assert.isTrue(foundation.currentAppBarOffsetTop_ === -1); +}); + +test('top app bar : topAppBarScrollHandler_ negative scroll results in currentAppBarOffsetTop_ being 0', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + td.when(mockAdapter.getViewportScrollY()).thenReturn(-1); + foundation.topAppBarScrollHandler_(); + assert.isTrue(foundation.currentAppBarOffsetTop_ === 0); +}); + +test('top app bar : topAppBarScrollHandler_ scroll greater than top app bar height results in ' + + 'currentAppBarOffsetTop_ being negative topAppBarHeight_', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + td.when(mockAdapter.getViewportScrollY()).thenReturn(100); + foundation.topAppBarScrollHandler_(); + assert.isTrue(foundation.currentAppBarOffsetTop_ === -64); +}); + +test('top app bar : topAppBarScrollHandler_ scrolling does not generate a ' + + 'positive currentAppBarOffsetTop_', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + td.when(mockAdapter.getViewportScrollY()).thenReturn(100); + foundation.topAppBarScrollHandler_(); + td.when(mockAdapter.getViewportScrollY()).thenReturn(-100); + foundation.topAppBarScrollHandler_(); + assert.isTrue(foundation.currentAppBarOffsetTop_ === 0); +}); + +test('top app bar : resize events should set isCurrentlyBeingResized_ to true', () => { + const mockRaf = createMockRaf(); + const {foundation, mockAdapter} = setupTest(); + const {resizeHandler} = createMockHandlers(foundation, mockAdapter, mockRaf); + + foundation.init(); + resizeHandler(); + + assert.isTrue(foundation.isCurrentlyBeingResized_); +}); + +test('top app bar : resize events throttle multiple calls of throttledResizeHandler_ ', () => { + const clock = lolex.install(); + const mockRaf = createMockRaf(); + const {foundation, mockAdapter} = setupTest(); + const {resizeHandler} = createMockHandlers(foundation, mockAdapter, mockRaf); + + foundation.init(); + resizeHandler(); + assert.isFalse(!foundation.resizeThrottleId_); + resizeHandler(); + clock.tick(numbers.DEBOUNCE_THROTTLE_RESIZE_TIME_MS); + assert.isTrue(!foundation.resizeThrottleId_); +}); + +test('top app bar : resize events debounce changing isCurrentlyBeingResized_ to false ', () => { + const clock = lolex.install(); + const mockRaf = createMockRaf(); + const {foundation, mockAdapter} = setupTest(); + const {resizeHandler} = createMockHandlers(foundation, mockAdapter, mockRaf); + + foundation.init(); + + resizeHandler(); + const debounceId = foundation.resizeDebounceId_; + clock.tick(50); + resizeHandler(); + assert.isFalse(debounceId === foundation.resizeDebounceId_); + assert.isTrue(foundation.isCurrentlyBeingResized_); + clock.tick(150); + assert.isFalse(foundation.isCurrentlyBeingResized_); +}); diff --git a/test/unit/mdc-top-app-bar/util.test.js b/test/unit/mdc-top-app-bar/util.test.js new file mode 100644 index 00000000000..e69de29bb2d