diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 884a325f546e..7464d57455a9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -167,6 +167,7 @@ /e2e/components/stepper-e2e.spec.ts @mmalerba /e2e/components/tabs-e2e.spec.ts @andrewseguin /e2e/components/toolbar-e2e.spec.ts @devversion +/e2e/components/virtual-scroll-e2e.spec.ts @mmalerba /e2e/util/** @jelbourn /src/e2e-app/* @jelbourn /src/e2e-app/block-scroll-strategy/** @andrewseguin @crisbeto @@ -185,6 +186,7 @@ /src/e2e-app/sidenav/** @mmalerba /src/e2e-app/slide-toggle/** @devversion /src/e2e-app/tabs/** @andrewseguin +/src/e2e-app/virtual-scroll/** @mmalerba # Universal app /src/universal-app/** @jelbourn diff --git a/e2e/components/virtual-scroll-e2e.spec.ts b/e2e/components/virtual-scroll-e2e.spec.ts new file mode 100644 index 000000000000..57084e95000e --- /dev/null +++ b/e2e/components/virtual-scroll-e2e.spec.ts @@ -0,0 +1,117 @@ +import {browser, by, element, ElementFinder} from 'protractor'; +import {ILocation, ISize} from 'selenium-webdriver'; + +declare var window: any; + + +describe('autosize cdk-virtual-scroll', () => { + let viewport: ElementFinder; + + describe('with uniform items', () => { + beforeEach(() => { + browser.get('/virtual-scroll'); + viewport = element(by.css('.demo-virtual-scroll-uniform-size cdk-virtual-scroll-viewport')); + }); + + it('should scroll down slowly', async () => { + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="39"]')); + const onScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="40"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + }); + + it('should jump scroll position down and slowly scroll back up', async () => { + // The estimate of the total content size is exactly correct, so we wind up scrolled to the + // same place as if we slowly scrolled down. + await browser.executeAsyncScript(scrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="39"]')); + const onScreen = element(by.css('.demo-virtual-scroll-uniform-size [data-index="40"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + + // As we slowly scroll back up we should wind up back at the start of the content. + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 0); + const first = element(by.css('.demo-virtual-scroll-uniform-size [data-index="0"]')); + expect(await isVisibleInViewport(first, viewport)).toBe(true); + }); + }); + + describe('with variable size', () => { + beforeEach(() => { + browser.get('/virtual-scroll'); + viewport = element(by.css('.demo-virtual-scroll-variable-size cdk-virtual-scroll-viewport')); + }); + + it('should scroll down slowly', async () => { + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="19"]')); + const onScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="20"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + }); + + it('should jump scroll position down and slowly scroll back up', async () => { + // The estimate of the total content size is slightly different than the actual, so we don't + // wind up in the same spot as if we scrolled slowly down. + await browser.executeAsyncScript(scrollViewportTo, viewport, 2000); + const offScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="18"]')); + const onScreen = element(by.css('.demo-virtual-scroll-variable-size [data-index="19"]')); + expect(await isVisibleInViewport(offScreen, viewport)).toBe(false); + expect(await isVisibleInViewport(onScreen, viewport)).toBe(true); + + // As we slowly scroll back up we should wind up back at the start of the content. As we + // scroll the error from when we jumped the scroll position should be slowly corrected. + await browser.executeAsyncScript(smoothScrollViewportTo, viewport, 0); + const first = element(by.css('.demo-virtual-scroll-variable-size [data-index="0"]')); + expect(await isVisibleInViewport(first, viewport)).toBe(true); + }); + }); +}); + + +/** Checks if the given element is visible in the given viewport. */ +async function isVisibleInViewport(el: ElementFinder, viewport: ElementFinder): Promise { + if (!await el.isPresent() || !await el.isDisplayed() || !await viewport.isPresent() || + !await viewport.isDisplayed()) { + return false; + } + const viewportRect = getRect(await viewport.getLocation(), await viewport.getSize()); + const elRect = getRect(await el.getLocation(), await el.getSize()); + return elRect.left < viewportRect.right && elRect.right > viewportRect.left && + elRect.top < viewportRect.bottom && elRect.bottom > viewportRect.top; +} + + +/** Gets the rect for an element given its location ans size. */ +function getRect(location: ILocation, size: ISize): + {top: number, left: number, bottom: number, right: number} { + return { + top: location.y, + left: location.x, + bottom: location.y + size.height, + right: location.x + size.width + }; +} + + +/** Immediately scrolls the viewport to the given offset. */ +function scrollViewportTo(viewportEl: any, offset: number, done: () => void) { + viewportEl.scrollTop = offset; + window.requestAnimationFrame(() => done()); +} + + +/** Smoothly scrolls the viewport to the given offset, 25px at a time. */ +function smoothScrollViewportTo(viewportEl: any, offset: number, done: () => void) { + let promise = Promise.resolve(); + let curOffset = viewportEl.scrollTop; + do { + const co = curOffset += Math.min(25, Math.max(-25, offset - curOffset)); + promise = promise.then(() => new Promise(resolve => { + viewportEl.scrollTop = co; + window.requestAnimationFrame(() => resolve()); + })); + } while (curOffset != offset); + promise.then(() => done()); +} diff --git a/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss index 60f6c3b03edf..b68799167b1e 100644 --- a/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss +++ b/src/cdk-experimental/scrolling/virtual-scroll-viewport.scss @@ -14,11 +14,11 @@ cdk-virtual-scroll-viewport { will-change: contents, transform; } -.cdk-virtual-scroll-orientation-horizontal { +.cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper { bottom: 0; } -.cdk-virtual-scroll-orientation-vertical { +.cdk-virtual-scroll-orientation-vertical .cdk-virtual-scroll-content-wrapper { right: 0; } diff --git a/src/e2e-app/e2e-app-module.ts b/src/e2e-app/e2e-app-module.ts index ecb85b2763da..3f2aa9776358 100644 --- a/src/e2e-app/e2e-app-module.ts +++ b/src/e2e-app/e2e-app-module.ts @@ -1,24 +1,7 @@ +import {ScrollingModule} from '@angular/cdk-experimental'; +import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay'; import {NgModule} from '@angular/core'; -import {BrowserModule} from '@angular/platform-browser'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; -import {RouterModule} from '@angular/router'; -import {SimpleCheckboxes} from './checkbox/checkbox-e2e'; -import {E2EApp, Home} from './e2e-app/e2e-app'; -import {IconE2E} from './icon/icon-e2e'; -import {ButtonE2E} from './button/button-e2e'; -import {MenuE2E} from './menu/menu-e2e'; -import {SimpleRadioButtons} from './radio/radio-e2e'; -import {BasicTabs} from './tabs/tabs-e2e'; -import {DialogE2E, TestDialog} from './dialog/dialog-e2e'; -import {GridListE2E} from './grid-list/grid-list-e2e'; -import {ProgressBarE2E} from './progress-bar/progress-bar-e2e'; -import {ProgressSpinnerE2E} from './progress-spinner/progress-spinner-e2e'; -import {FullscreenE2E, TestDialogFullScreen} from './fullscreen/fullscreen-e2e'; -import {E2E_APP_ROUTES} from './e2e-app/routes'; -import {SlideToggleE2E} from './slide-toggle/slide-toggle-e2e'; -import {InputE2E} from './input/input-e2e'; -import {SidenavE2E} from './sidenav/sidenav-e2e'; -import {BlockScrollStrategyE2E} from './block-scroll-strategy/block-scroll-strategy-e2e'; +import {ReactiveFormsModule} from '@angular/forms'; import { MatButtonModule, MatCheckboxModule, @@ -39,9 +22,28 @@ import { MatStepperModule, MatTabsModule, } from '@angular/material'; -import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay'; import {ExampleModule} from '@angular/material-examples'; -import {ReactiveFormsModule} from '@angular/forms'; +import {BrowserModule} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {RouterModule} from '@angular/router'; +import {BlockScrollStrategyE2E} from './block-scroll-strategy/block-scroll-strategy-e2e'; +import {ButtonE2E} from './button/button-e2e'; +import {SimpleCheckboxes} from './checkbox/checkbox-e2e'; +import {DialogE2E, TestDialog} from './dialog/dialog-e2e'; +import {E2EApp, Home} from './e2e-app/e2e-app'; +import {E2E_APP_ROUTES} from './e2e-app/routes'; +import {FullscreenE2E, TestDialogFullScreen} from './fullscreen/fullscreen-e2e'; +import {GridListE2E} from './grid-list/grid-list-e2e'; +import {IconE2E} from './icon/icon-e2e'; +import {InputE2E} from './input/input-e2e'; +import {MenuE2E} from './menu/menu-e2e'; +import {ProgressBarE2E} from './progress-bar/progress-bar-e2e'; +import {ProgressSpinnerE2E} from './progress-spinner/progress-spinner-e2e'; +import {SimpleRadioButtons} from './radio/radio-e2e'; +import {SidenavE2E} from './sidenav/sidenav-e2e'; +import {SlideToggleE2E} from './slide-toggle/slide-toggle-e2e'; +import {BasicTabs} from './tabs/tabs-e2e'; +import {VirtualScrollE2E} from './virtual-scroll/virtual-scroll-e2e'; /** * NgModule that contains all Material modules that are required to serve the e2e-app. @@ -66,6 +68,7 @@ import {ReactiveFormsModule} from '@angular/forms'; MatStepperModule, MatTabsModule, MatNativeDateModule, + ScrollingModule, ] }) export class E2eMaterialModule {} @@ -98,7 +101,8 @@ export class E2eMaterialModule {} SlideToggleE2E, TestDialog, TestDialogFullScreen, - BlockScrollStrategyE2E + BlockScrollStrategyE2E, + VirtualScrollE2E, ], bootstrap: [E2EApp], providers: [ diff --git a/src/e2e-app/e2e-app/e2e-app.html b/src/e2e-app/e2e-app/e2e-app.html index b1e16277e1eb..a28e711eaf86 100644 --- a/src/e2e-app/e2e-app/e2e-app.html +++ b/src/e2e-app/e2e-app/e2e-app.html @@ -22,6 +22,7 @@ Tabs Cards Toolbar + Virtual Scroll
diff --git a/src/e2e-app/e2e-app/routes.ts b/src/e2e-app/e2e-app/routes.ts index db20eb234048..58a2e48e7b7b 100644 --- a/src/e2e-app/e2e-app/routes.ts +++ b/src/e2e-app/e2e-app/routes.ts @@ -1,4 +1,5 @@ import {Routes} from '@angular/router'; +import {VirtualScrollE2E} from '../virtual-scroll/virtual-scroll-e2e'; import {Home} from './e2e-app'; import {ButtonE2E} from '../button/button-e2e'; import {BasicTabs} from '../tabs/tabs-e2e'; @@ -47,4 +48,5 @@ export const E2E_APP_ROUTES: Routes = [ {path: 'tabs', component: BasicTabs}, {path: 'cards', component: CardFancyExample}, {path: 'toolbar', component: ToolbarMultirowExample}, + {path: 'virtual-scroll', component: VirtualScrollE2E}, ]; diff --git a/src/e2e-app/tsconfig-build.json b/src/e2e-app/tsconfig-build.json index e369b61a4a58..af370e60fdef 100644 --- a/src/e2e-app/tsconfig-build.json +++ b/src/e2e-app/tsconfig-build.json @@ -26,6 +26,10 @@ "@angular/cdk/*": ["./cdk/*"], "@angular/material": ["./material"], "@angular/material/*": ["./material/*"], + "@angular/material-experimental/*": ["./material-experimental/*"], + "@angular/material-experimental": ["./material-experimental/"], + "@angular/cdk-experimental/*": ["./cdk-experimental/*"], + "@angular/cdk-experimental": ["./cdk-experimental/"], "@angular/material-moment-adapter": ["./material-moment-adapter"], "@angular/material-examples": ["./material-examples"] } diff --git a/src/e2e-app/virtual-scroll/virtual-scroll-e2e.css b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.css new file mode 100644 index 000000000000..adf74dad8e06 --- /dev/null +++ b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.css @@ -0,0 +1,13 @@ +.demo-viewport { + height: 300px; + width: 300px; + box-shadow: 0 0 0 1px black; +} + +.demo-item { + background: magenta; +} + +.demo-odd { + background: cyan; +} diff --git a/src/e2e-app/virtual-scroll/virtual-scroll-e2e.html b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.html new file mode 100644 index 000000000000..182542cbe186 --- /dev/null +++ b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.html @@ -0,0 +1,19 @@ +
+

Uniform size

+ +
+ Uniform Item #{{i}} - ({{size}}px) +
+
+
+ +
+

Random size

+ +
+ Variable Item #{{i}} - ({{size}}px) +
+
+
diff --git a/src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts new file mode 100644 index 000000000000..991d1caf8a68 --- /dev/null +++ b/src/e2e-app/virtual-scroll/virtual-scroll-e2e.ts @@ -0,0 +1,16 @@ +import {Component} from '@angular/core'; + + +const itemSizeSample = [100, 25, 50, 50, 100, 200, 75, 100, 50, 250]; + + +@Component({ + moduleId: module.id, + selector: 'virtual-scroll-e2e', + templateUrl: 'virtual-scroll-e2e.html', + styleUrls: ['virtual-scroll-e2e.css'], +}) +export class VirtualScrollE2E { + uniformItems = Array(1000).fill(50); + variableItems = Array(100).fill(0).reduce(acc => acc.concat(itemSizeSample), []); +} diff --git a/tools/gulp/tasks/e2e.ts b/tools/gulp/tasks/e2e.ts index e49b2ed29ba6..242e10866b13 100644 --- a/tools/gulp/tasks/e2e.ts +++ b/tools/gulp/tasks/e2e.ts @@ -2,7 +2,7 @@ import {task} from 'gulp'; import {join} from 'path'; import {ngcBuildTask, copyTask, execNodeTask, serverTask} from '../util/task_helpers'; import {copySync} from 'fs-extra'; -import {buildConfig, sequenceTask, watchFiles} from 'material2-build-tools'; +import {buildConfig, sequenceTask, triggerLivereload, watchFiles} from 'material2-build-tools'; // There are no type definitions available for these imports. const gulpConnect = require('gulp-connect'); @@ -13,6 +13,7 @@ const {outputDir, packagesDir, projectDir} = buildConfig; const releasesDir = join(outputDir, 'releases'); const appDir = join(packagesDir, 'e2e-app'); +const e2eTestDir = join(projectDir, 'e2e'); const outDir = join(outputDir, 'packages', 'e2e-app'); const PROTRACTOR_CONFIG_PATH = join(projectDir, 'test/protractor.conf.js'); @@ -21,9 +22,7 @@ const tsconfigPath = join(outDir, 'tsconfig-build.json'); /** Glob that matches all files that need to be copied to the output folder. */ const assetsGlob = join(appDir, '**/*.+(html|css|json|ts)'); -/** - * Builds and serves the e2e-app and runs protractor once the e2e-app is ready. - */ +/** Builds and serves the e2e-app and runs protractor once the e2e-app is ready. */ task('e2e', sequenceTask( [':test:protractor:setup', 'serve:e2eapp'], ':test:protractor', @@ -31,6 +30,34 @@ task('e2e', sequenceTask( 'screenshots', )); +/** + * Builds and serves the e2e-app and runs protractor when the app is ready. Re-runs protractor when + * the app or tests change. + */ +task('e2e:watch', sequenceTask( + [':test:protractor:setup', 'serve:e2eapp'], + [':test:protractor', 'material:watch', ':e2e:watch'], +)); + +/** Watches the e2e app and tests for changes and triggers a test rerun on change. */ +task(':e2e:watch', () => { + watchFiles([join(appDir, '**/*.+(html|ts|css)'), join(e2eTestDir, '**/*.+(html|ts)')], + [':e2e:rerun'], false); +}); + +/** Updates the e2e app and runs the protractor tests. */ +task(':e2e:rerun', sequenceTask( + 'e2e-app:copy-assets', + 'e2e-app:build-ts', + ':e2e:reload', + ':test:protractor' +)); + +/** Triggers a reload of the e2e app. */ +task(':e2e:reload', () => { + return triggerLivereload(); +}); + /** Task that builds the e2e-app in AOT mode. */ task('e2e-app:build', sequenceTask( 'clean', diff --git a/tools/package-tools/rollup-globals.ts b/tools/package-tools/rollup-globals.ts index 19ae925047c8..fe36cf5dbd0b 100644 --- a/tools/package-tools/rollup-globals.ts +++ b/tools/package-tools/rollup-globals.ts @@ -6,23 +6,35 @@ import {buildConfig} from './build-config'; export const dashCaseToCamelCase = (str: string) => str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()); +/** Generates rollup entry point mappings for the given package and entry points. */ +function generateRollupEntryPoints(packageName: string, entryPoints: string[]): + {[k: string]: string} { + return entryPoints.reduce((globals: {[k: string]: string}, entryPoint: string) => { + globals[`@angular/${packageName}/${entryPoint}`] = + `ng.${dashCaseToCamelCase(packageName)}.${dashCaseToCamelCase(entryPoint)}`; + return globals; + }, {}); +} + /** List of potential secondary entry-points for the cdk package. */ const cdkSecondaryEntryPoints = getSubdirectoryNames(join(buildConfig.packagesDir, 'cdk')); /** List of potential secondary entry-points for the material package. */ const matSecondaryEntryPoints = getSubdirectoryNames(join(buildConfig.packagesDir, 'lib')); +/** List of potential secondary entry-points for the cdk-experimental package. */ +const cdkExperimentalSecondaryEntryPoints = + getSubdirectoryNames(join(buildConfig.packagesDir, 'cdk-experimental')); + /** Object with all cdk entry points in the format of Rollup globals. */ -const rollupCdkEntryPoints = cdkSecondaryEntryPoints.reduce((globals: any, entryPoint: string) => { - globals[`@angular/cdk/${entryPoint}`] = `ng.cdk.${dashCaseToCamelCase(entryPoint)}`; - return globals; -}, {}); +const rollupCdkEntryPoints = generateRollupEntryPoints('cdk', cdkSecondaryEntryPoints); /** Object with all material entry points in the format of Rollup globals. */ -const rollupMatEntryPoints = matSecondaryEntryPoints.reduce((globals: any, entryPoint: string) => { - globals[`@angular/material/${entryPoint}`] = `ng.material.${dashCaseToCamelCase(entryPoint)}`; - return globals; -}, {}); +const rollupMatEntryPoints = generateRollupEntryPoints('material', matSecondaryEntryPoints); + +/** Object with all cdk-experimental entry points in the format of Rollup globals. */ +const rollupCdkExperimentalEntryPoints = + generateRollupEntryPoints('cdk-experimental', cdkExperimentalSecondaryEntryPoints); /** Map of globals that are used inside of the different packages. */ export const rollupGlobals = { @@ -55,6 +67,7 @@ export const rollupGlobals = { // Include secondary entry-points of the cdk and material packages ...rollupCdkEntryPoints, ...rollupMatEntryPoints, + ...rollupCdkExperimentalEntryPoints, 'rxjs': 'Rx', 'rxjs/operators': 'Rx.operators',