├── .editorconfig ├── .eslintignore ├── .eslintrc.yaml ├── .gitignore ├── .travis.yml ├── README.md ├── SECURITY.md ├── demos ├── element-container │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ └── spec │ │ └── index.js ├── internal-container │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ ├── list.jade │ └── spec │ │ └── index.js ├── non-virtualized │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ └── spec │ │ └── index.js ├── variant-height │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ └── spec │ │ └── index.js ├── viewport │ ├── demo.config.json │ ├── index.jade │ ├── index.js │ ├── index.less │ ├── mark.jade │ ├── mark.js │ ├── mark.less │ └── spec │ │ └── index.js └── window-container │ ├── demo.config.json │ ├── index.css │ ├── index.jade │ ├── index.js │ └── spec │ └── index.js ├── gulpfile.js ├── js ├── default-item.jade ├── default-list.jade ├── index.js └── viewport.js ├── jsdoc.json ├── karma.conf.js ├── package.json ├── spec ├── .eslintrc.yaml ├── alternative-list.jade ├── index.js ├── initial-list.jade ├── internal-viewport-list.jade ├── test-container.jade ├── test-util.js ├── viewport-detection-element.jade ├── viewport-detection-window.jade ├── viewport-detection.js └── viewport.js ├── speclist.js ├── wdio.conf.js ├── webpack.alias.js ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # npm packages 2 | node_modules 3 | 4 | # code coverage 5 | coverage 6 | 7 | # output folder 8 | dist 9 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | "parser": "babel-eslint" 3 | "extends": "xo-space" 4 | "env": 5 | "mocha": true 6 | "amd": true 7 | "browser": true 8 | "rules": 9 | "comma-dangle": 10 | - 2 11 | - "always-multiline" 12 | "object-curly-spacing": 13 | - 2 14 | - "always" 15 | "linebreak-style": 0 16 | "curly": 17 | - 2 18 | - "multi-line" 19 | "max-nested-callbacks": 20 | - 2 21 | - 10 22 | "arrow-parens": 0 23 | "babel/arrow-parens": 24 | - 2 25 | - "as-needed" 26 | "generator-star-spacing": 0 27 | "babel/generator-star-spacing": 2 28 | "plugins": 29 | - "babel" 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm packages 2 | node_modules 3 | 4 | # code coverage 5 | coverage 6 | 7 | # test output 8 | errorShots 9 | test-results 10 | 11 | # output folder 12 | dist 13 | docs 14 | 15 | # npm logs 16 | node-debug.log 17 | npm-debug.log 18 | 19 | # npm config 20 | .npmrc 21 | 22 | # generated code for example pages 23 | examples/requirejs/require.config.js 24 | examples/webpack/dist 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - v5 4 | - v4 5 | - '0.12' 6 | before_script: 7 | - export DISPLAY=:99.0 8 | - sh -e /etc/init.d/xvfb start 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [backbone-virtualized-listview][git-repo] 2 | [![NPM version][npm-image]][npm-url] 3 | [![Build Status][travis-image]][travis-url] 4 | [![Dependency Status][daviddm-image]][daviddm-url] 5 | [![Coverage percentage][coveralls-image]][coveralls-url] 6 | > Backbone list view with virtualization support 7 | 8 | UI virtualization is essential to your Web UI performance in case you have 9 | thousands of data item to render. *The idea is to skip rendering the off screen 10 | items and replace them with filler blocks. You need to handle the scroll and 11 | resize events to adjust the DOM content.* 12 | 13 | The principle is straight forward, but the implementation is fussy. This 14 | [Backbone][backbonejs] based implementation is aiming to create a general 15 | purposed virtualized view with high quality and performance, so that people can 16 | focus more on the user experience instead of the complexity of virtualization. 17 | 18 | ## Features 19 | 20 | ### Customization 21 | The `ListView` is named as "list view", but it's not necessarily to render a 22 | list. You can customize it into a `TABLE`, or a sequence of `DIV`s with the 23 | `listTemplate` and the `itemTemplate` options. 24 | 25 | Refer to the [document][docs-list-view] for detail 26 | 27 | ### Scroll to item 28 | You can scroll a certain item into the viewport, method `scrollToItem` is 29 | the helper. 30 | 31 | Refer to the [document][docs-scroll-to-item] for detail. 32 | 33 | ### Handling data change 34 | When data is changed, you can update the view with the `set` method. 35 | 36 | Refer to the [document][docs-set] for detail. 37 | 38 | ## Installation 39 | ```bash 40 | # install the module 41 | npm install --save backbone-virtualized-listview 42 | # install the peer dependencies 43 | npm install --save jquery underscore backbone fast-binary-indexed-tree 44 | ``` 45 | 46 | ## Usage 47 | 48 | Refer to the [document][docs] for details. 49 | 50 | ```javascript 51 | import _ from 'underscore'; 52 | import ListView from 'backbone-virtualized-listview'; 53 | import listTemplate from 'my-list-template.jade'; 54 | import itemTemplate from 'my-item-template.jade'; 55 | 56 | const listView = new ListView({ 57 | el: '.container', 58 | }).set({ 59 | items: _.map(_.range(2000), i => { text: i }), 60 | listTemplate, 61 | itemTemplate, 62 | }); 63 | listView.render(); 64 | 65 | // Scroll to item 66 | listView.scrollToItem(100); 67 | ``` 68 | 69 | ## License 70 | 71 | MIT 72 | 73 | This project has adopted the [Microsoft Open Source Code of Conduct][ms-code-of-conduct]. 74 | For more information see the [Code of Conduct FAQ][ms-code-of-conduct-faq] 75 | or contact [opencode@microsoft.com][ms-mailto] with any additional questions or comments. 76 | 77 | [backbonejs]: http://backbonejs.org/ 78 | [docs]: https://microsoft.github.io/backbone-virtualized-listview/ 79 | [docs-list-view]: https://microsoft.github.io/backbone-virtualized-listview/ListView.html 80 | [docs-scroll-to-item]: https://microsoft.github.io/backbone-virtualized-listview/ListView.html#scrollToItem__anchor 81 | [docs-set]: https://microsoft.github.io/backbone-virtualized-listview/ListView.html#set__anchor 82 | 83 | [ms-code-of-conduct]: https://opensource.microsoft.com/codeofconduct/ 84 | [ms-code-of-conduct-faq]: https://opensource.microsoft.com/codeofconduct/faq/ 85 | [ms-mailto]: mailto:opencode@microsoft.com 86 | 87 | [git-repo]: https://github.com/Microsoft/backbone-virtualized-listview 88 | [npm-image]: https://badge.fury.io/js/backbone-virtualized-listview.svg 89 | [npm-url]: https://npmjs.org/package/backbone-virtualized-listview 90 | [travis-image]: https://travis-ci.org/Microsoft/backbone-virtualized-listview.svg?branch=master 91 | [travis-url]: https://travis-ci.org/Microsoft/backbone-virtualized-listview 92 | [daviddm-image]: https://david-dm.org/Microsoft/backbone-virtualized-listview.svg?theme=shields.io 93 | [daviddm-url]: https://david-dm.org/Microsoft/backbone-virtualized-listview 94 | [coveralls-image]: https://coveralls.io/repos/Microsoft/backbone-virtualized-listview/badge.svg 95 | [coveralls-url]: https://coveralls.io/r/Microsoft/backbone-virtualized-listview 96 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /demos/element-container/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/element-container/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | height: 500px; 4 | overflow: scroll; 5 | background: cyan; 6 | } 7 | -------------------------------------------------------------------------------- /demos/element-container/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container(tabIndex="0") 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/element-container/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import ListView from '../../js/index'; 3 | import 'style!css!./index.css'; 4 | 5 | window.listView = new ListView({ 6 | el: '.container', 7 | }).set({ 8 | items: _.map(_.range(200000), i => ({ text: i })), 9 | defaultItemHeight: 40, 10 | }).render(); 11 | -------------------------------------------------------------------------------- /demos/element-container/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demos/internal-container/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/internal-container/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | height: 500px; 4 | overflow: scroll; 5 | background: cyan; 6 | } 7 | -------------------------------------------------------------------------------- /demos/internal-container/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container(tabIndex="0") 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/internal-container/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import listTemplate from './list.jade'; 3 | import ListView from '../../js/index'; 4 | import 'style!css!./index.css'; 5 | 6 | window.listView = new ListView({ 7 | el: '.container', 8 | viewport: '.viewport', 9 | }).set({ 10 | model: { title: 'Internal Viewport' }, 11 | items: _.map(_.range(200000), i => ({ text: i })), 12 | defaultItemHeight: 40, 13 | listTemplate, 14 | }).render(); 15 | 16 | -------------------------------------------------------------------------------- /demos/internal-container/list.jade: -------------------------------------------------------------------------------- 1 | h2=title 2 | .viewport(style={ height: '600px' }) 3 | ul 4 | .top-filler 5 | .bottom-filler 6 | 7 | -------------------------------------------------------------------------------- /demos/internal-container/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demos/non-virtualized/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/non-virtualized/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: cyan; 3 | } 4 | -------------------------------------------------------------------------------- /demos/non-virtualized/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/non-virtualized/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'underscore'; 3 | import ListView from '../../js/index'; 4 | import 'style!css!./index.css'; 5 | 6 | const listView = window.listView = new ListView({ 7 | virtualized: false, 8 | }).set({ 9 | items: _.map(_.range(2000), i => ({ text: i })), 10 | }).render(); 11 | $('.container').append(listView.$el); 12 | -------------------------------------------------------------------------------- /demos/non-virtualized/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demos/variant-height/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/variant-height/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: relative; 3 | height: 500px; 4 | width: 200px; 5 | overflow: scroll; 6 | background: cyan; 7 | } 8 | 9 | li { 10 | word-wrap: break-word; 11 | } 12 | -------------------------------------------------------------------------------- /demos/variant-height/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container(tabIndex="0") 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/variant-height/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import ListView from '../../js/index'; 3 | import 'style!css!./index.css'; 4 | 5 | window.listView = new ListView({ el: '.container' }).set({ 6 | items: _.map(_.range(20000), i => ({ 7 | text: `${i}: ${_.map(_.range(_.random(50)), () => _.random(9)).join('')}`, 8 | })), 9 | }).render(); 10 | 11 | -------------------------------------------------------------------------------- /demos/variant-height/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /demos/viewport/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/viewport/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | #window-position 6 | #content 7 | #outer 8 | #inner 9 | | Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod 10 | | tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At 11 | | vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, 12 | | no sea takimata sanctus est Lorem ipsum dolor sit amet. 13 | script(src=bundle) 14 | -------------------------------------------------------------------------------- /demos/viewport/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'underscore'; 3 | import { WindowViewport, ElementViewport } from '../../js/viewport.js'; 4 | import { Mark } from './mark.js'; 5 | import './index.less'; 6 | 7 | const vpWindow = new WindowViewport(); 8 | const vpElement = new ElementViewport(document.getElementById('outer')); 9 | const marks = _.map(_.range(4), () => new Mark().render()); 10 | 11 | _.each(marks, mark => document.body.appendChild(mark.el)); 12 | 13 | function update() { 14 | const innerEl = vpElement.getMetrics().inner; 15 | const windowWidth = window.innerWidth; 16 | const windowHeight = window.innerHeight; 17 | 18 | marks[0].model.set({ x: innerEl.left, y: innerEl.top, windowWidth, windowHeight }); 19 | marks[1].model.set({ x: innerEl.left, y: innerEl.bottom, windowWidth, windowHeight }); 20 | marks[2].model.set({ x: innerEl.right, y: innerEl.top, windowWidth, windowHeight }); 21 | marks[3].model.set({ x: innerEl.right, y: innerEl.bottom, windowWidth, windowHeight }); 22 | 23 | const innerWin = vpWindow.getMetrics().inner; 24 | const posWin = _.chain([ 25 | 'left', 26 | 'top', 27 | 'right', 28 | 'bottom', 29 | 'width', 30 | 'height', 31 | ]).map(key => `${key}: ${innerWin[key]}`).join('; ').value(); 32 | $('#window-position').text(posWin); 33 | } 34 | 35 | update(); 36 | 37 | vpWindow.on('change', update); 38 | vpElement.on('change', update); 39 | -------------------------------------------------------------------------------- /demos/viewport/index.less: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | } 5 | 6 | #window-position { 7 | position: fixed; 8 | height: 20px; 9 | width: 100%; 10 | background: LightBlue; 11 | padding-left: 20px; 12 | } 13 | 14 | #content { 15 | width: 1000px; 16 | height: 600px; 17 | 18 | #outer { 19 | width: 100px; 20 | height: 100px; 21 | 22 | #inner { 23 | width: 200px; 24 | height: 200px; 25 | } 26 | } 27 | } 28 | 29 | #outer { 30 | position: relative; 31 | left: 200px; 32 | top: 200px; 33 | } 34 | -------------------------------------------------------------------------------- /demos/viewport/mark.jade: -------------------------------------------------------------------------------- 1 | .mark 2 | .row.top 3 | .cell.left 4 | span.text 5 | = textTopLeft 6 | .cell.right 7 | span.text 8 | = textTopRight 9 | .row.bottom 10 | .cell.left 11 | span.text 12 | = textBottomLeft 13 | .cell.right 14 | span.text 15 | = textBottomRight 16 | 17 | -------------------------------------------------------------------------------- /demos/viewport/mark.js: -------------------------------------------------------------------------------- 1 | import Backbone from 'backbone'; 2 | import template from './mark.jade'; 3 | import './mark.less'; 4 | 5 | export class Mark extends Backbone.View { 6 | initialize({ 7 | model = new Backbone.Model({ 8 | x: 10, 9 | y: 10, 10 | windowHeight: window.innerHeight, 11 | windowWidth: window.innerWidth, 12 | }), 13 | } = {}) { 14 | this.model = model; 15 | this.model.on('change', () => this.render()); 16 | } 17 | 18 | render() { 19 | const { x, y, windowHeight, windowWidth } = this.model.toJSON(); 20 | const center = { 21 | x: windowWidth / 2, 22 | y: windowHeight / 2, 23 | }; 24 | const pos = `(${x}, ${y})`; 25 | const options = {}; 26 | 27 | if (x > center.x) { 28 | if (y > center.y) { 29 | options.textTopLeft = pos; 30 | } else { 31 | options.textBottomLeft = pos; 32 | } 33 | } else if (y > center.y) { 34 | options.textTopRight = pos; 35 | } else { 36 | options.textBottomRight = pos; 37 | } 38 | 39 | this.$el.html(template(options)); 40 | this.$('.mark').css({ 41 | left: x - 10, 42 | top: y - 10, 43 | }); 44 | return this; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /demos/viewport/mark.less: -------------------------------------------------------------------------------- 1 | @text-distance: 3px; 2 | 3 | .mark { 4 | position: fixed; 5 | 6 | .text { 7 | position: absolute; 8 | } 9 | 10 | .top { 11 | border-bottom-style: solid; 12 | .cell { 13 | vertical-align: bottom; 14 | .text { 15 | bottom: @text-distance; 16 | } 17 | } 18 | } 19 | .bottom { 20 | // border-top-style: solid; 21 | .cell { 22 | vertical-align: top; 23 | .text { 24 | top: @text-distance; 25 | } 26 | } 27 | } 28 | .left { 29 | border-right-style: solid; 30 | .text { 31 | right: @text-distance; 32 | } 33 | } 34 | .right { 35 | // border-left-style: solid; 36 | .text { 37 | left: @text-distance; 38 | } 39 | } 40 | 41 | .cell { 42 | border-width: 1px; 43 | display: inline-block; 44 | width: 10px; 45 | height: 10px; 46 | margin: 0; 47 | padding: 0; 48 | white-space: nowrap; 49 | position: relative; 50 | } 51 | 52 | .row { 53 | border-width: 1px; 54 | margin: 0; 55 | padding: 0; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /demos/viewport/spec/index.js: -------------------------------------------------------------------------------- 1 | describe('viewport', function () { 2 | }); 3 | -------------------------------------------------------------------------------- /demos/window-container/demo.config.json: -------------------------------------------------------------------------------- 1 | { "template": "./index.jade" } 2 | -------------------------------------------------------------------------------- /demos/window-container/index.css: -------------------------------------------------------------------------------- 1 | .container { 2 | background: cyan; 3 | } 4 | -------------------------------------------------------------------------------- /demos/window-container/index.jade: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title=title 4 | body 5 | h1 Outer Header 6 | .container 7 | h1 Inner Header 8 | script(src=bundle) 9 | -------------------------------------------------------------------------------- /demos/window-container/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'underscore'; 3 | import ListView from '../../js/index'; 4 | import 'style!css!./index.css'; 5 | 6 | const listView = window.listView = new ListView().set({ 7 | items: _.map(_.range(20000), i => ({ text: i })), 8 | }).render(); 9 | $('.container').append(listView.$el); 10 | -------------------------------------------------------------------------------- /demos/window-container/spec/index.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | /* global browser */ 4 | /* eslint no-unused-expressions: 0 */ 5 | describe('webdriver.io page', function () { 6 | it('should render the head line correctly', function () { 7 | var headLine = browser.element('h1'); 8 | expect(headLine).to.be.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var os = require('os'); 3 | var http = require('http'); 4 | var path = require('path'); 5 | var childProcess = require('child_process'); 6 | var resolve = require('resolve'); 7 | var gulp = require('gulp'); 8 | var gutil = require('gulp-util'); 9 | var eslint = require('gulp-eslint'); 10 | var democase = require('gulp-democase'); 11 | var excludeGitignore = require('gulp-exclude-gitignore'); 12 | var webpack = require('webpack'); 13 | var del = require('del'); 14 | // coveralls 15 | var coveralls = require('gulp-coveralls'); 16 | // coveralls-end 17 | var jsdoc = require('gulp-jsdoc3'); 18 | 19 | function webpackBuild(configFilePath) { 20 | return function (cb) { 21 | webpack(require(configFilePath), function (err, stats) { 22 | gutil.log(stats.toString({ colors: true })); 23 | cb(err || stats.hasErrors() && new Error('webpack compile error')); 24 | }); 25 | }; 26 | } 27 | 28 | function getSeleniumFilePath() { 29 | var SELENIUM_NAME = 'selenium-server-standalone-2.53.0.jar'; 30 | return path.resolve(os.tmpdir(), SELENIUM_NAME); 31 | } 32 | 33 | gulp.task('download-selenium', function (cb) { 34 | var filePath = getSeleniumFilePath(); 35 | fs.stat(filePath, function (err) { 36 | if (!err) { 37 | return cb(null); 38 | } 39 | var file = fs.createWriteStream(filePath); 40 | var URL = 'http://selenium-release.storage.googleapis.com/2.53/selenium-server-standalone-2.53.0.jar'; 41 | http.get(URL, function (response) { 42 | response.pipe(file); 43 | }); 44 | file.on('error', function (err) { 45 | fs.unlinkSync(filePath); 46 | cb(err); 47 | }); 48 | file.on('finish', cb); 49 | }); 50 | }); 51 | 52 | function startSeleniumServer() { 53 | var filePath = getSeleniumFilePath(); 54 | return childProcess.spawn('java', ['-jar', filePath], { 55 | stdio: 'inherit', 56 | env: { path: path.join(__dirname, 'node_modules', '.bin') }, 57 | }); 58 | } 59 | 60 | function testWithKarmaCmd(handler) { 61 | var karmaCmd = path.resolve('./node_modules/.bin/karma'); 62 | 63 | if (process.platform === 'win32') { 64 | karmaCmd += '.cmd'; 65 | } 66 | 67 | childProcess.spawn(karmaCmd, [ 68 | 'start', 69 | '--single-run', 70 | ], { stdio: 'inherit' }).on('close', handler); 71 | } 72 | 73 | /* 74 | function testWithKarmaAPI(handler) { 75 | var Server = require('karma').Server; 76 | new Server({ 77 | configFile: path.join(__dirname, 'karma.conf.js'), 78 | singleRun: true, 79 | }, handler).start(); 80 | } 81 | */ 82 | 83 | gulp.task('test:unit', function (cb) { 84 | var handler = function (code) { 85 | if (code) { 86 | cb(new Error('test failure')); 87 | } else { 88 | cb(); 89 | } 90 | }; 91 | testWithKarmaCmd(handler); 92 | }); 93 | 94 | // coveralls 95 | gulp.task('coveralls', ['test'], function () { 96 | if (!process.env.CI) { 97 | return; 98 | } 99 | 100 | return gulp.src(path.join(__dirname, 'coverage/report-lcov/lcov.info')) 101 | .pipe(coveralls()); 102 | }); 103 | // coveralls-end 104 | 105 | gulp.task('static', function () { 106 | return gulp.src(['js/**/*.js', 'demos/**/*.js', 'spec/**/*.js']) 107 | .pipe(excludeGitignore()) 108 | .pipe(eslint()) 109 | .pipe(eslint.format()) 110 | .pipe(eslint.failAfterError()); 111 | }); 112 | 113 | gulp.task('jsdoc', function (cb) { 114 | gulp.src(['README.md', './src/**/*.js'], { read: false }) 115 | .pipe(jsdoc(require('./jsdoc.json'), cb)); 116 | }); 117 | 118 | gulp.task('webpack', webpackBuild('./webpack.config')); 119 | 120 | gulp.task('demos', function () { 121 | return gulp.src('./demos').pipe(democase()); 122 | }); 123 | 124 | gulp.task('test:demos', ['download-selenium'], function (done) { 125 | var pathCli = path.resolve(path.dirname(resolve.sync('webdriverio', { 126 | basedir: '.', 127 | })), 'lib/cli'); 128 | var cpSelenium = null; 129 | var cpWdio = null; 130 | 131 | cpSelenium = startSeleniumServer().on('error', function () { 132 | if (cpWdio) { 133 | cpWdio.kill(); 134 | } 135 | done(new Error('Failed to launch the selenium standalone server. Make sure you have JRE available')); 136 | }); 137 | 138 | cpWdio = childProcess.fork(pathCli, [path.join(__dirname, 'wdio.conf.js')], { 139 | env: { DEMOCASE_HTTP_PORT: 8081 }, 140 | }).on('close', function (code) { 141 | cpSelenium.kill(); 142 | if (code) { 143 | done(new Error('selenium test failue')); 144 | } 145 | done(); 146 | }); 147 | }); 148 | 149 | gulp.task('test', ['test:unit']); 150 | 151 | gulp.task('prepublish', ['webpack']); 152 | 153 | gulp.task('clean:test', function () { 154 | return del(['coverage']); 155 | }); 156 | 157 | gulp.task('clean:build', function () { 158 | return del(['dist']); 159 | }); 160 | 161 | gulp.task('clean', ['clean:build', 'clean:test']); 162 | 163 | gulp.task('default', [ 164 | 'static', 165 | 'webpack', 166 | // coveralls 167 | 'coveralls', 168 | // coveralls-end 169 | ]); 170 | -------------------------------------------------------------------------------- /js/default-item.jade: -------------------------------------------------------------------------------- 1 | li=text 2 | -------------------------------------------------------------------------------- /js/default-list.jade: -------------------------------------------------------------------------------- 1 | ul.list-container 2 | .top-filler 3 | .bottom-filler 4 | -------------------------------------------------------------------------------- /js/index.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import $ from 'jquery'; 3 | import Backbone from 'backbone'; 4 | import BinaryIndexedTree from 'fast-binary-indexed-tree'; 5 | 6 | import defaultListTemplate from './default-list.jade'; 7 | import defaultItemTemplate from './default-item.jade'; 8 | import { ElementViewport, WindowViewport } from './viewport.js'; 9 | 10 | // Helper function to created a scoped while loop 11 | const whileTrue = func => { 12 | while (func()); 13 | }; 14 | 15 | const INVALIDATION_NONE = 0; 16 | const INVALIDATION_ITEMS = 0x1; 17 | const INVALIDATION_EVENTS = 0x2; 18 | const INVALIDATION_LIST = 0x4; 19 | const INVALIDATION_ALL = 0x7; 20 | 21 | const LIST_VIEW_EVENTS = ['willRedraw', 'didRedraw']; 22 | 23 | /** 24 | * The virtualized list view class. 25 | * 26 | * In addition to ordinary Backbone View options, the constructor also takes 27 | * 28 | * __virtualized__: whether or not the virtualization is enabled. 29 | * 30 | * __viewport__: the option locate the scrollable viewport. It can be 31 | * 32 | * * Omitted, auto detect the closest ancestor of the `$el` with 'overflowY' 33 | * style being 'auto' or 'scroll'. Use the window viewport if found none. 34 | * * A `string`, use it as a selector to select an __internal__ element as 35 | * the viewport. 36 | * * An `HTMLElement` or `jQuery`, use it as the viewport element. 37 | * * The `window`, use the window viewport. 38 | * 39 | * @param {Object} options The constructor options. 40 | * @param {boolean} [options.virtualized=true] 41 | * @param {string | HTMLElement | jQuery | window} [options.viewport] 42 | * 43 | */ 44 | 45 | class ListView extends Backbone.View { 46 | 47 | /** 48 | * Backbone view initializer 49 | * @see ListView 50 | */ 51 | initialize({ 52 | virtualized = true, 53 | viewport = null, 54 | } = {}) { 55 | this._props = { virtualized, viewport }; 56 | this.options = { 57 | model: {}, 58 | listTemplate: defaultListTemplate, 59 | events: {}, 60 | items: [], 61 | itemTemplate: defaultItemTemplate, 62 | defaultItemHeight: 20, 63 | }; 64 | 65 | // States 66 | this._state = { 67 | indexFirst: 0, 68 | indexLast: 0, 69 | anchor: null, 70 | invalidation: INVALIDATION_NONE, 71 | removed: false, 72 | eventsListView: {}, 73 | }; 74 | 75 | this._scheduleRedraw = _.noop; 76 | } 77 | 78 | _initViewport() { 79 | const viewport = this._props.viewport; 80 | 81 | if (_.isString(viewport)) { 82 | return new ElementViewport(this.$(viewport)); 83 | } else if (viewport instanceof $) { 84 | if (viewport.get(0) === window) { 85 | return new WindowViewport(); 86 | } 87 | return new ElementViewport(viewport); 88 | } else if (viewport instanceof HTMLElement) { 89 | return new ElementViewport(viewport); 90 | } else if (viewport === window) { 91 | return new WindowViewport(); 92 | } 93 | 94 | let $el = this.$el; 95 | while ($el.length > 0 && !$el.is(document)) { 96 | if (_.contains(['auto', 'scroll'], $el.css('overflowY'))) { 97 | return new ElementViewport($el); 98 | } 99 | $el = $el.parent(); 100 | } 101 | return new WindowViewport(); 102 | } 103 | 104 | _hookUpViewport() { 105 | this.viewport = this._initViewport(); 106 | 107 | if (this.virtualized) { 108 | let blockUntil = 0; 109 | 110 | const onViewportChange = () => { 111 | if (performance.now() > blockUntil) { 112 | this._scheduleRedraw(); 113 | } else if (!this._state.removed) { 114 | // If the scroll events are blocked, we shouldn't just swallow them. 115 | // Wait for 0.1 second and give another try. 116 | window.setTimeout(onViewportChange, 100); 117 | } 118 | }; 119 | 120 | this.viewport.on('change', onViewportChange); 121 | 122 | // 123 | // On keypress, we want to block the scroll events for 0.2 second to wait 124 | // for the animation to complete. Otherwise, the scroll would change the 125 | // geometry metrics and break the animation. The worst thing we may get is, 126 | // for 'HOME' and 'END' keys, the view doesn't scroll to the right position. 127 | // 128 | this.viewport.on('keypress', () => { 129 | blockUntil = performance.now() + 200; 130 | }); 131 | } 132 | } 133 | 134 | /** 135 | * Whether or not the list view is virtualized 136 | */ 137 | get virtualized() { 138 | return this._props.virtualized; 139 | } 140 | 141 | /** 142 | * Remove the view and unregister the event listeners. 143 | */ 144 | remove() { 145 | this._state.removed = true; 146 | if (this.viewport) { 147 | this.viewport.remove(); 148 | } 149 | super.remove(); 150 | } 151 | 152 | _applyPaddings({ paddingTop, paddingBottom }) { 153 | if (this.$topFiller && this.$bottomFiller) { 154 | this.$topFiller.height(paddingTop); 155 | this.$bottomFiller.height(paddingBottom); 156 | } 157 | } 158 | 159 | _processInvalidation() { 160 | const { items, events, listTemplate, model } = this.options; 161 | const { invalidation } = this._state; 162 | const eventsDOM = _.omit(events, LIST_VIEW_EVENTS); 163 | const eventsListView = _.pick(events, LIST_VIEW_EVENTS); 164 | 165 | if (invalidation & INVALIDATION_EVENTS) { 166 | this.undelegateEvents(); 167 | _.each(this._state.eventsListView || {}, (handler, event) => { 168 | this.off(event, handler); 169 | }); 170 | } 171 | if (invalidation & INVALIDATION_LIST) { 172 | const isInternalViewport = _.isString(this._props.viewport); 173 | if (isInternalViewport && this.viewport) { 174 | this.viewport.remove(); 175 | this.viewport = null; 176 | } 177 | this.$el.html(listTemplate(model)); 178 | if (!this.viewport) { 179 | this._hookUpViewport(); 180 | } 181 | this.$topFiller = this.$('.top-filler'); 182 | this.$bottomFiller = this.$('.bottom-filler'); 183 | this._applyPaddings({ 184 | paddingTop: 0, 185 | paddingBottom: this.itemHeights.read(items.length), 186 | }); 187 | _.extend(this._state, { indexFirst: 0, indexLast: 0 }); 188 | } 189 | if (invalidation & INVALIDATION_EVENTS) { 190 | this.delegateEvents(eventsDOM); 191 | _.each(eventsListView, (handler, event) => { 192 | this.on(event, handler); 193 | }); 194 | this._state.eventsListView = eventsListView; 195 | } 196 | const invalidateItems = invalidation & INVALIDATION_ITEMS; 197 | 198 | _.extend(this._state, { invalidation: INVALIDATION_NONE }); 199 | return invalidateItems; 200 | } 201 | 202 | // Private API, redraw immediately 203 | _redraw() { 204 | let invalidateItems = this._processInvalidation(); 205 | const { items, itemTemplate } = this.options; 206 | const { viewport, itemHeights, $topFiller, $bottomFiller, virtualized } = this; 207 | let { indexFirst, indexLast, anchor } = this._state; 208 | 209 | if (!invalidateItems && items.length === 0) { 210 | return; 211 | } 212 | 213 | /** 214 | * The event indicates the list will start redraw. 215 | * @event ListView#willRedraw 216 | */ 217 | this.trigger('willRedraw'); 218 | 219 | whileTrue(() => { 220 | let isCompleted = true; 221 | 222 | const metricsViewport = viewport.getMetrics(); 223 | const visibleTop = metricsViewport.outer.top; 224 | const visibleBot = metricsViewport.outer.bottom; 225 | const listTopCur = this.$topFiller.get(0).getBoundingClientRect().top; 226 | const scrollRatio = metricsViewport.scroll.ratioY; 227 | 228 | let renderTop = false; 229 | let renderBot = false; 230 | 231 | whileTrue(() => { 232 | const listTop = anchor ? anchor.top - itemHeights.read(anchor.index) : listTopCur; 233 | const targetFirst = virtualized ? itemHeights.lowerBound(visibleTop - listTop) : 0; 234 | const targetLast = virtualized ? Math.min(itemHeights.upperBound(visibleBot - listTop) + 1, items.length) : items.length; 235 | const renderFirst = Math.max(targetFirst - 10, 0); 236 | const renderLast = Math.min(targetLast + 10, items.length); 237 | 238 | let renderMore = false; 239 | 240 | // Clean up 241 | if (targetFirst >= indexLast || targetLast <= indexFirst || invalidateItems) { 242 | $topFiller.nextUntil($bottomFiller).remove(); 243 | indexFirst = indexLast = targetFirst; 244 | if (targetFirst !== targetLast && items.length > 0) { 245 | renderMore = true; 246 | } 247 | if (!anchor) { 248 | const index = Math.round(targetFirst * (1 - scrollRatio) + targetLast * scrollRatio); 249 | const top = listTopCur + itemHeights.read(index); 250 | anchor = { index, top }; 251 | } 252 | invalidateItems = false; 253 | } else if (!anchor) { 254 | const index = Math.round(indexFirst * (1 - scrollRatio) + indexLast * scrollRatio); 255 | const top = listTopCur + itemHeights.read(index); 256 | anchor = { index, top }; 257 | } 258 | 259 | // Render top 260 | if (targetFirst < indexFirst) { 261 | $topFiller.after(items.slice(renderFirst, indexFirst).map(itemTemplate)); 262 | $topFiller.nextUntil($bottomFiller).slice(0, indexFirst - renderFirst).each((offset, el) => { 263 | itemHeights.writeSingle(renderFirst + offset, el.getBoundingClientRect().height); 264 | }); 265 | indexFirst = renderFirst; 266 | renderMore = renderTop = true; 267 | } else if (renderBot && !renderTop && renderFirst > indexFirst) { 268 | const removal = []; 269 | $topFiller.nextUntil($bottomFiller).slice(0, renderFirst - indexFirst).each((offset, el) => removal.push(el)); 270 | $(removal).remove(); 271 | indexFirst = renderFirst; 272 | renderMore = true; 273 | } 274 | 275 | // Render bottom 276 | if (targetLast > indexLast) { 277 | $bottomFiller.before(items.slice(indexLast, renderLast).map(itemTemplate)); 278 | $topFiller.nextUntil($bottomFiller).slice(indexLast - indexFirst).each((offset, el) => { 279 | itemHeights.writeSingle(indexLast + offset, el.getBoundingClientRect().height); 280 | }); 281 | indexLast = renderLast; 282 | renderMore = renderBot = true; 283 | } else if (renderTop && !renderBot && renderLast < indexLast) { 284 | const removal = []; 285 | $topFiller.nextUntil($bottomFiller).slice(renderLast - indexFirst).each((offset, el) => removal.push(el)); 286 | $(removal).remove(); 287 | indexLast = renderLast; 288 | renderMore = true; 289 | } 290 | 291 | return renderMore; 292 | }); 293 | 294 | // Update the padding 295 | if (indexFirst !== this.indexFirst || indexLast !== this.indexLast) { 296 | this._applyPaddings({ 297 | paddingTop: itemHeights.read(indexFirst), 298 | paddingBottom: itemHeights.read(items.length) - itemHeights.read(indexLast), 299 | }); 300 | } 301 | 302 | // Adjust the scroll if it's changed significantly 303 | const listTop = anchor.top - itemHeights.read(anchor.index); 304 | const innerTop = listTop - (listTopCur - metricsViewport.inner.top); 305 | const scrollTop = Math.round(visibleTop - innerTop); 306 | let anchorNew = null; 307 | 308 | // Do a second scroll for a middle anchor after the item is rendered 309 | if (anchor.isMiddle) { 310 | const index = anchor.index; 311 | const itemTop = listTopCur + this.itemHeights.read(index); 312 | const itemBot = listTopCur + this.itemHeights.read(index + 1); 313 | 314 | anchorNew = { 315 | index, 316 | top: (visibleTop + visibleBot + itemTop - itemBot) / 2, 317 | }; 318 | isCompleted = false; 319 | } 320 | 321 | if (Math.abs(scrollTop - viewport.getMetrics().scroll.y) >= 1) { 322 | this.viewport.scrollTo({ y: scrollTop }); 323 | isCompleted = false; 324 | } 325 | 326 | anchor = anchorNew; 327 | 328 | return !isCompleted; 329 | }); 330 | 331 | // Write back the render state 332 | _.extend(this._state, { indexFirst, indexLast, anchor: null }); 333 | 334 | /** 335 | * The event indicates the list view have completed redraw. 336 | * @event ListView#didRedraw 337 | */ 338 | this.trigger('didRedraw'); 339 | } 340 | 341 | /** 342 | * Get the item at certain index. 343 | * @param {number} index The index of the item. 344 | * @return {Object} 345 | */ 346 | itemAt(index) { 347 | return _.first(this.options.items.slice(index, index + 1)); 348 | } 349 | 350 | /** 351 | * Get the rendered DOM element at certain index. 352 | * @param {number} index The index of the item. 353 | * @return {HTMLElement} 354 | */ 355 | elementAt(index) { 356 | const { indexFirst, indexLast } = this._state; 357 | 358 | if (index < indexFirst || index >= indexLast || !this.$topFiller || !this.$bottomFiller) { 359 | return null; 360 | } 361 | return this.$topFiller.nextUntil(this.$bottomFiller).get(index - indexFirst); 362 | } 363 | 364 | /** 365 | * The index of the first rendered item. 366 | * @type {number} 367 | */ 368 | get indexFirst() { 369 | return this._state.indexFirst; 370 | } 371 | 372 | /** 373 | * The index after the last rendered item. 374 | * @type {number} 375 | */ 376 | get indexLast() { 377 | return this._state.indexLast; 378 | } 379 | 380 | /** 381 | * The total count of the items. 382 | * @type {number} 383 | */ 384 | get length() { 385 | return this.options.items.length; 386 | } 387 | 388 | /** 389 | * The model object to render the skeleton of the list view. 390 | * @type {Object} 391 | */ 392 | get model() { 393 | return this.options.model; 394 | } 395 | 396 | /** 397 | * The template to render the skeleton of the list view. 398 | * @callback ListView~cbListTemplate 399 | * @param {Object} model The model object of the list view. 400 | */ 401 | 402 | /** 403 | * The template to render the skeleton of the list view. 404 | * @type {ListView~cbListTemplate} 405 | */ 406 | get listTemplate() { 407 | return this.options.listTemplate; 408 | } 409 | 410 | /** 411 | * The template to render a list item. 412 | * @callback ListView~cbItemTemplate 413 | * @param {Object} item The model object of the item 414 | */ 415 | 416 | /** 417 | * The template to render a list item. 418 | * @type {ListView~cbItemTemplate} 419 | */ 420 | get itemTemplate() { 421 | return this.options.itemTemplate; 422 | } 423 | 424 | /** 425 | * The default list item height. 426 | * @type {number} 427 | */ 428 | get defaultItemHeight() { 429 | return this.options.defaultItemHeight; 430 | } 431 | 432 | /** 433 | * @external BinaryIndexedTree 434 | * @see {@link https://microsoft.github.io/fast-binary-indexed-tree-js/BinaryIndexedTree.html} 435 | */ 436 | 437 | /** 438 | * The BinaryIndexedTree to get the heights and accumulated heights of items. 439 | * @type {external:BinaryIndexedTree} 440 | */ 441 | get itemHeights() { 442 | if (!this._itemHeights) { 443 | const { defaultItemHeight, items } = this.options; 444 | this._itemHeights = new BinaryIndexedTree({ 445 | defaultFrequency: Math.max(defaultItemHeight, 1), 446 | maxVal: items.length, 447 | }); 448 | } 449 | return this._itemHeights; 450 | } 451 | 452 | /** 453 | * Set the list view options. The following options can be set 454 | * 455 | * __model__: The model object to render the skeleton of the list view. 456 | * 457 | * __listTemplate__: The template to render the skeleton of the list view. 458 | * 459 | * * By default, it would render a single `UL`. 460 | * * __Note__: It must contain the following elements with specified class 461 | * names as the first and last siblings of the list items. All list items 462 | * will be rendered in between. 463 | * * `'top-filler'`: The filler block on top. 464 | * * `'bottom-filler'`: The filler block at bottom. 465 | * 466 | * __events__: The events hash in form of `{ "event selector": callback }`. 467 | * 468 | * * Refer to {@link http://backbonejs.org/#View-events|Backbone.View~events} 469 | * * In addition to the DOM events, it can also handle the `'willRedraw'` and 470 | * `'didRedraw'` events of the list view. 471 | * * __Note__: The callback __MUST__ be a function. Member function names are 472 | * not supported. 473 | * 474 | * __items__: The model objects of the list items. 475 | * 476 | * __itemTemplate__: The template to render a list item. 477 | * 478 | * * By default, it would render a single `LI` filled with `item.text`. 479 | * * __Note__: list items __MUST NOT__ have outer margins, otherwise the layout 480 | * calculation will be inaccurate. 481 | * 482 | * __defaultItemHeight__: The estimated height of a single item. 483 | * 484 | * * It's not necessary to be accurate. But the accurater it is, the less the 485 | * scroll bar is adjusted overtime. 486 | * 487 | * Refer to {@link ListView} for detail. 488 | * 489 | * @param {Object} options The new options. 490 | * @param {Object} options.model 491 | * @param {ListView~cbListTemplate} [options.listTemplate] 492 | * @param {Object} options.events 493 | * @param {Object[]} [options.items=[]] 494 | * @param {ListView~cbItemTemplate} [options.itemTemplate] 495 | * @param {number} [options.defaultItemHeight=20] 496 | * @param {function} [callback] The callback to notify completion. 497 | * @return {ListView} The list view itself. 498 | */ 499 | set(options = {}, callback = _.noop) { 500 | const isSet = key => !_.isUndefined(options[key]); 501 | const itemHeightsCur = this._itemHeights; 502 | let invalidation = 0; 503 | 504 | _.extend(this.options, options); 505 | 506 | if (_.some(['model', 'listTemplate'], isSet)) { 507 | invalidation |= INVALIDATION_ALL; 508 | } else { 509 | if (_.some(['items', 'itemTemplate', 'defaultItemHeight'], isSet)) { 510 | if (isSet('defaultItemHeight') || 511 | this.itemHeights.maxVal !== this.length) { 512 | this._itemHeights = null; 513 | } 514 | invalidation |= INVALIDATION_ITEMS; 515 | } 516 | if (isSet('events')) { 517 | invalidation |= INVALIDATION_EVENTS; 518 | } 519 | } 520 | 521 | if (invalidation) { 522 | if (this.viewport && this.$topFiller && this.$topFiller.length > 0 && itemHeightsCur) { 523 | const visibleTop = this.viewport.getMetrics().outer.top; 524 | const listTopCur = this.$topFiller.get(0).getBoundingClientRect().top; 525 | const visibleFirst = itemHeightsCur.lowerBound(visibleTop - listTopCur); 526 | 527 | if (visibleFirst < this.length) { 528 | const el = this.elementAt(visibleFirst); 529 | if (el) { 530 | const elTop = el.getBoundingClientRect().top; 531 | this._state.anchor = { 532 | index: visibleFirst, 533 | top: elTop, 534 | }; 535 | } 536 | } 537 | } 538 | 539 | this._invalidate(invalidation, callback); 540 | } else { 541 | callback(); 542 | } 543 | 544 | return this; 545 | } 546 | 547 | _invalidate(invalidation, callback) { 548 | this._state.invalidation |= invalidation; 549 | this._scheduleRedraw(true); 550 | this.once('didRedraw', callback); 551 | } 552 | 553 | /** 554 | * Invalidate the already rendered items and schedule another redraw. 555 | * @param {function} [callback] The callback to notify completion. 556 | */ 557 | invalidate(callback = _.noop) { 558 | this._invalidate(INVALIDATION_ITEMS, callback); 559 | } 560 | 561 | /** 562 | * Scroll to a certain item. 563 | * @param {number} index The index of the item. 564 | * @param {string|number} [position='default'] The position of the item. 565 | * 566 | * The valid positions are 567 | * * `'default'`, if the item is above the viewport top, scroll it to the 568 | * top, if the item is below the viewport bottom, scroll it to the bottom, 569 | * otherwise, keep the viewport unchanged. 570 | * * `'top'`, scroll the item to top of the viewport. 571 | * * `'middle'`, scroll the item to the vertical center of the viewport. 572 | * * `'bottom'`, scroll the item to the bottom of the viewport. 573 | * * `{number}`, scroll the item to the given offset from the viewport top. 574 | * 575 | * @param {function} [callback] The callback to notify completion. 576 | * 577 | */ 578 | scrollToItem(...args) { 579 | if (!this.$topFiller || !this.$bottomFiller) { 580 | throw new Error('Cannot scroll before the view is rendered'); 581 | } 582 | let index = 0; 583 | let position = 'default'; 584 | let callback = _.noop; 585 | 586 | if (args.length >= 3) { 587 | [index, position, callback] = args; 588 | } else if (args.length === 2) { 589 | if (_.isFunction(args[1])) { 590 | [index, callback] = args; 591 | } else { 592 | [index, position] = args; 593 | } 594 | } else if (args.length === 1) { 595 | index = args[0]; 596 | } 597 | this._scrollToItem(index, position, callback); 598 | } 599 | 600 | _scrollToItem(index, position, callback) { 601 | const metricsViewport = this.viewport.getMetrics(); 602 | const visibleTop = metricsViewport.outer.top; 603 | const visibleBot = metricsViewport.outer.bottom; 604 | const listTopCur = this.$topFiller.get(0).getBoundingClientRect().top; 605 | const itemTop = listTopCur + this.itemHeights.read(index); 606 | const itemBot = listTopCur + this.itemHeights.read(index + 1); 607 | let pos = position; 608 | 609 | if (pos === 'default') { 610 | if (itemTop < visibleTop) { 611 | pos = 'top'; 612 | } else if (itemBot > visibleBot) { 613 | pos = 'bottom'; 614 | } else { 615 | if (_.isFunction(callback)) { 616 | callback(); 617 | } 618 | return; 619 | } 620 | } 621 | 622 | if (pos === 'top') { 623 | this._state.anchor = { 624 | index, 625 | top: visibleTop, 626 | }; 627 | } else if (pos === 'bottom') { 628 | this._state.anchor = { 629 | index: index + 1, 630 | top: visibleBot, 631 | }; 632 | } else if (pos === 'middle') { 633 | this._state.anchor = { 634 | index: index, 635 | top: (visibleTop + visibleBot + itemTop - itemBot) / 2, 636 | isMiddle: true, 637 | }; 638 | } else if (typeof pos === 'number') { 639 | this._state.anchor = { 640 | index: index, 641 | top: visibleTop + pos, 642 | }; 643 | } else { 644 | throw new Error('Invalid position'); 645 | } 646 | 647 | this.once('didRedraw', callback); 648 | 649 | this._scheduleRedraw(); 650 | } 651 | 652 | /** 653 | * Render the list view. 654 | * @param {function} [callback] The callback to notify completion. 655 | */ 656 | render(callback = _.noop) { 657 | let animationFrameId = null; 658 | let timeoutId = null; 659 | 660 | const redraw = () => { 661 | animationFrameId = null; 662 | timeoutId = null; 663 | if (!this._state.removed) { 664 | this._redraw(); 665 | } 666 | }; 667 | 668 | this._scheduleRedraw = (ignoreAnimationFrame = false) => { 669 | if (!timeoutId) { 670 | if (ignoreAnimationFrame) { 671 | timeoutId = window.setTimeout(redraw, 0); 672 | if (animationFrameId) { 673 | window.cancelAnimationFrame(animationFrameId); 674 | animationFrameId = null; 675 | } 676 | } else if (!animationFrameId) { 677 | animationFrameId = window.requestAnimationFrame(redraw); 678 | } 679 | } 680 | }; 681 | // this._hookUpViewport(); 682 | this._invalidate(INVALIDATION_ALL, callback); 683 | return this; 684 | } 685 | 686 | } 687 | 688 | export default ListView; 689 | -------------------------------------------------------------------------------- /js/viewport.js: -------------------------------------------------------------------------------- 1 | import Backbone from 'backbone'; 2 | import $ from 'jquery'; 3 | import _ from 'underscore'; 4 | 5 | function getElementMetrics(el) { 6 | return _.pick(el.getBoundingClientRect(), [ 7 | 'left', 8 | 'top', 9 | 'right', 10 | 'bottom', 11 | 'width', 12 | 'height', 13 | ]); 14 | } 15 | 16 | function calculateRatio(scroll, scrollMax) { 17 | return scrollMax > 0 ? Math.min(Math.max(scroll / scrollMax, 0), 1) : 0; 18 | } 19 | 20 | export class Viewport { 21 | constructor($el) { 22 | _.extend(this, Backbone.Events); 23 | 24 | this.$el = $el; 25 | 26 | this.onScroll = () => { 27 | this.trigger('scroll'); 28 | this.trigger('change'); 29 | }; 30 | 31 | this.onResize = () => { 32 | this.trigger('resize'); 33 | this.trigger('change'); 34 | }; 35 | 36 | let keyCode = null; 37 | let timestamp = performance.now(); 38 | this.onKeydown = event => { 39 | // Consolidate the keydown events for the same key in 0.2 seconds 40 | if (keyCode !== event.keyCode || performance.now() > timestamp + 200) { 41 | keyCode = event.keyCode; 42 | timestamp = performance.now(); 43 | this.trigger('keypress', keyCode); 44 | } 45 | }; 46 | 47 | this.onKeyup = () => { 48 | keyCode = null; 49 | }; 50 | 51 | this.$el.on('resize', this.onResize); 52 | this.$el.on('scroll', this.onScroll); 53 | $(document).on('keydown', this.onKeydown); 54 | $(document).on('keyup', this.onKeyup); 55 | 56 | this.scrollTo = scrollNew => { 57 | if (_.isNumber(scrollNew.x)) { 58 | this.$el.scrollLeft(scrollNew.x); 59 | } 60 | if (_.isNumber(scrollNew.y)) { 61 | this.$el.scrollTop(scrollNew.y); 62 | } 63 | }; 64 | } 65 | 66 | remove() { 67 | this.$el.off('resize', this.onResize); 68 | this.$el.off('scroll', this.onScroll); 69 | $(document).off('keydown', this.onKeydown); 70 | $(document).off('keyup', this.onKeyup); 71 | } 72 | 73 | getMetrics() { 74 | throw new Error('Not implemented'); 75 | } 76 | } 77 | 78 | export class WindowViewport extends Viewport { 79 | constructor() { 80 | super($(window)); 81 | } 82 | 83 | getMetrics() { 84 | const inner = getElementMetrics(document.documentElement); 85 | 86 | inner.width = document.documentElement.scrollWidth; 87 | inner.height = document.documentElement.scrollHeight; 88 | inner.right = inner.left + inner.width; 89 | inner.bottom = inner.top + inner.height; 90 | 91 | const outer = { 92 | top: 0, 93 | bottom: window.innerHeight, 94 | left: 0, 95 | right: window.innerWidth, 96 | width: window.innerWidth, 97 | height: window.innerHeight, 98 | }; 99 | 100 | const scroll = { 101 | x: window.pageXOffset, 102 | y: window.pageYOffset, 103 | }; 104 | 105 | scroll.ratioX = calculateRatio(scroll.x, inner.width - outer.width); 106 | scroll.ratioY = calculateRatio(scroll.y, inner.height - outer.height); 107 | 108 | return { inner, outer, scroll }; 109 | } 110 | } 111 | 112 | const SCROLLABLE = ['auto', 'scroll']; 113 | 114 | export class ElementViewport extends Viewport { 115 | constructor(el) { 116 | super($(el)); 117 | 118 | this.el = this.$el.get(0); 119 | this.$el.css('overflowX', s => _.contains(SCROLLABLE, s) ? s : 'auto'); 120 | this.$el.css('overflowY', s => _.contains(SCROLLABLE, s) ? s : 'auto'); 121 | } 122 | 123 | getMetrics() { 124 | const outer = getElementMetrics(this.el); 125 | const scroll = { 126 | x: this.el.scrollLeft, 127 | y: this.el.scrollTop, 128 | }; 129 | const inner = { 130 | left: outer.left - scroll.x, 131 | top: outer.top - scroll.y, 132 | width: this.el.scrollWidth, 133 | height: this.el.scrollHeight, 134 | }; 135 | inner.right = inner.left + inner.width; 136 | inner.bottom = inner.top + inner.height; 137 | 138 | scroll.ratioX = calculateRatio(scroll.x, inner.width - outer.width); 139 | scroll.ratioY = calculateRatio(scroll.y, inner.height - outer.height); 140 | 141 | return { outer, inner, scroll }; 142 | } 143 | 144 | } 145 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": ["js"], 4 | "includePattern": ".+\\.js$" 5 | }, 6 | "opts": { 7 | "destination": "docs", 8 | "recurse": true 9 | }, 10 | "plugins": ["plugins/markdown"] 11 | } 12 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore'); 2 | var path = require('path'); 3 | 4 | function getWebpackConfig() { 5 | var webpackConfig = _.omit(require('./webpack.config'), 'entry', 'externals'); 6 | _.defaults(webpackConfig, { module: {} }); 7 | 8 | webpackConfig.module.preLoaders = [{ 9 | test: /\.js$/, 10 | include: path.resolve('./js/'), 11 | loader: 'babel', 12 | }, { 13 | test: /\.js$/, 14 | include: path.resolve('./js/'), 15 | loader: 'isparta', 16 | }, { 17 | test: /sinon\.js$/, 18 | loader: 'imports?define=>false,require=>false', 19 | }].concat(webpackConfig.module.preLoaders || []); 20 | 21 | _.defaults(webpackConfig, { resolve: {} }); 22 | 23 | _.extend(webpackConfig.resolve.alias, { 24 | sinon: 'sinon/pkg/sinon.js', 25 | }); 26 | 27 | return webpackConfig; 28 | } 29 | 30 | module.exports = function (config) { 31 | config.set({ 32 | files: [ 33 | 'speclist.js', 34 | ], 35 | 36 | frameworks: [ 37 | 'mocha', 38 | ], 39 | 40 | client: { 41 | mocha: { 42 | reporter: 'html', // change Karma's debug.html to the mocha web reporter 43 | }, 44 | }, 45 | 46 | reporters: ['mocha', 'coverage'], 47 | 48 | preprocessors: { 49 | 'speclist.js': 'webpack', 50 | }, 51 | 52 | webpack: getWebpackConfig(), 53 | 54 | coverageReporter: { 55 | dir: 'coverage/', 56 | reporters: [ 57 | { type: 'html', subdir: 'report-html' }, 58 | { type: 'lcov', subdir: 'report-lcov' }, 59 | ], 60 | }, 61 | 62 | browsers: [ 63 | // 'Electron', 64 | 'Firefox', 65 | // 'Chrome', 66 | ], 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/backbone-virtualized-listview.js", 3 | "version": "0.6.8", 4 | "files": [ 5 | "dist", 6 | "docs" 7 | ], 8 | "scripts": { 9 | "test": "gulp static test coveralls", 10 | "prepublish": "gulp prepublish" 11 | }, 12 | "license": "MIT", 13 | "name": "backbone-virtualized-listview", 14 | "description": "Virtualized list view for Backbone", 15 | "keywords": [ 16 | "Backbone", 17 | "virtualization", 18 | "list", 19 | "Web UI" 20 | ], 21 | "author": { 22 | "name": "Wei Wei", 23 | "email": "wewei@microsoft.com" 24 | }, 25 | "contributors": [ 26 | { 27 | "name": "Wei Wei", 28 | "email": "lyweiwei@outlook.com" 29 | } 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/Microsoft/backbone-virtualized-listview.git" 34 | }, 35 | "devDependencies": { 36 | "babel-core": "^6.9.0", 37 | "babel-eslint": "^6.1.2", 38 | "babel-loader": "^6.2.4", 39 | "babel-polyfill": "^6.9.1", 40 | "babel-preset-es2015": "^6.9.0", 41 | "babel-preset-stage-3": "^6.11.0", 42 | "backbone": "^1.3.3", 43 | "bluebird": "^3.4.1", 44 | "chai": "^3.5.0", 45 | "css-loader": "^0.23.1", 46 | "del": "^2.2.0", 47 | "democase": "^0.1.9", 48 | "eslint": "^2.10.2", 49 | "eslint-config-xo": "^0.14.1", 50 | "eslint-config-xo-space": "^0.13.0", 51 | "eslint-plugin-babel": "^3.3.0", 52 | "fast-binary-indexed-tree": "~1.0.0", 53 | "gulp": "^3.9.1", 54 | "gulp-coveralls": "^0.1.4", 55 | "gulp-democase": "0.0.1", 56 | "gulp-eslint": "^2.0.0", 57 | "gulp-exclude-gitignore": "^1.0.0", 58 | "gulp-file": "^0.3.0", 59 | "gulp-jsdoc3": "^0.3.0", 60 | "gulp-util": "^3.0.7", 61 | "imports-loader": "^0.6.5", 62 | "isparta-loader": "^2.0.0", 63 | "jade": "^1.11.0", 64 | "jade-loader": "^0.8.0", 65 | "jquery": "^2.2.4", 66 | "jsdoc": "^3.4.0", 67 | "karma": "^0.13.22", 68 | "karma-chrome-launcher": "^1.0.1", 69 | "karma-coverage": "^1.0.0", 70 | "karma-firefox-launcher": "^1.0.0", 71 | "karma-mocha": "^1.0.1", 72 | "karma-mocha-reporter": "^2.0.3", 73 | "karma-sourcemap-loader": "^0.3.7", 74 | "karma-webpack": "^1.7.0", 75 | "less": "^2.7.1", 76 | "less-loader": "^2.2.3", 77 | "lodash": "^4.13.1", 78 | "mocha": "^2.5.2", 79 | "requirejs": "^2.2.0", 80 | "resolve": "^1.1.7", 81 | "sinon": "^1.17.4", 82 | "sinon-chai": "^2.8.0", 83 | "source-map-loader": "^0.1.5", 84 | "style-loader": "^0.13.1", 85 | "underscore": "^1.8.3", 86 | "wdio-junit-reporter": "0.0.2", 87 | "wdio-mocha-framework": "^0.2.13", 88 | "webdriverio": "^4.0.7", 89 | "webpack": "^1.13.1", 90 | "webpack-stream": "^3.2.0" 91 | }, 92 | "peerDependencies": { 93 | "backbone": "^1.3.3", 94 | "jquery": "^2.2.4", 95 | "fast-binary-indexed-tree": "~1.0.0", 96 | "underscore": "^1.8.3" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /spec/.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | "rules": 3 | "no-unused-expressions": 0 4 | -------------------------------------------------------------------------------- /spec/alternative-list.jade: -------------------------------------------------------------------------------- 1 | h2=title 2 | .internal-viewport(style={ height: '500px' }) 3 | ul.list-container 4 | .top-filler 5 | .bottom-filler 6 | -------------------------------------------------------------------------------- /spec/index.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import _ from 'underscore'; 3 | import Backbone from 'backbone'; 4 | import chai from 'chai'; 5 | import sinon from 'sinon'; 6 | import sinonChai from 'sinon-chai'; 7 | import ListView from '../js/index.js'; 8 | import template from './test-container.jade'; 9 | import initialListTemplate from './initial-list.jade'; 10 | import alternativeListTemplate from './alternative-list.jade'; 11 | import { doAsync, sleep } from './test-util.js'; 12 | 13 | chai.use(sinonChai); 14 | 15 | const expect = chai.expect; 16 | 17 | const redrawInterval = 100; 18 | 19 | describe('ListView', function () { 20 | let listView = null; 21 | 22 | function render() { 23 | return new Promise(resolve => listView.render(resolve)); 24 | } 25 | 26 | beforeEach(function () { 27 | $('body').html(template(this.currentTest)); 28 | }); 29 | 30 | afterEach(function () { 31 | $('body').empty(); 32 | }); 33 | 34 | it('should be a Backbone View', function () { 35 | expect(ListView.prototype).is.instanceof(Backbone.View); 36 | }); 37 | 38 | describe('Properties', function () { 39 | const count = 20000; 40 | 41 | const model = { title: 'Test Properties' }; 42 | const listTemplate = alternativeListTemplate; 43 | const itemTemplate = item => `
  • ${item.text}
  • `; 44 | const defaultItemHeight = 18; 45 | 46 | beforeEach(doAsync(async () => { 47 | listView = new ListView({ 48 | el: '.test-container', 49 | }).set({ 50 | items: _.map(_.range(count), i => ({ text: i })), 51 | listTemplate, 52 | model, 53 | itemTemplate, 54 | defaultItemHeight, 55 | }); 56 | await render(); 57 | })); 58 | 59 | afterEach(doAsync(async () => { 60 | listView.remove(); 61 | await sleep(redrawInterval); 62 | })); 63 | 64 | it('should expose the length of the list', function () { 65 | expect(listView.length).to.equal(count); 66 | }); 67 | 68 | it('should be able to get the items', function () { 69 | expect(listView.itemAt(10)).to.deep.equal({ text: 10 }); 70 | }); 71 | 72 | it('should be able to get the elements', function () { 73 | expect(listView.elementAt(10000)).to.be.null; 74 | expect(listView.elementAt(1)).to.be.an.instanceof(HTMLElement); 75 | }); 76 | 77 | it('should expose the listTemplate', function () { 78 | expect(listView.listTemplate).to.equal(listTemplate); 79 | }); 80 | 81 | it('should expose the model', function () { 82 | expect(listView.model).to.equal(model); 83 | }); 84 | 85 | it('should expose the itemTemplate', function () { 86 | expect(listView.itemTemplate).to.equal(itemTemplate); 87 | }); 88 | 89 | it('should expose the defaultItemHeight', function () { 90 | expect(listView.defaultItemHeight).to.equal(defaultItemHeight); 91 | }); 92 | }); 93 | 94 | describe('Handling viewport events', function () { 95 | const count = 20000; 96 | 97 | beforeEach(doAsync(async () => { 98 | listView = new ListView({ 99 | el: '.test-container', 100 | }).set({ 101 | items: _.map(_.range(count), i => ({ text: i })), 102 | }); 103 | 104 | await render(); 105 | listView.viewport.scrollTo({ y: 0 }); 106 | await sleep(redrawInterval); 107 | })); 108 | 109 | afterEach(doAsync(async () => { 110 | listView.remove(); 111 | await sleep(redrawInterval); 112 | })); 113 | 114 | it('should trigger redraw on viewport change', doAsync(async () => { 115 | const spy = sinon.spy(); 116 | 117 | listView.once('didRedraw', spy); 118 | listView.viewport.trigger('change'); 119 | expect(spy).not.to.be.called; 120 | 121 | await sleep(150); 122 | expect(spy).to.be.calledOnce; 123 | })); 124 | 125 | it('should block redraw for 0.2 second when press a key', doAsync(async () => { 126 | const spy = sinon.spy(); 127 | 128 | listView.once('didRedraw', spy); 129 | listView.viewport.trigger('keypress'); 130 | listView.viewport.trigger('change'); 131 | expect(spy).not.to.be.called; 132 | 133 | await sleep(150); 134 | expect(spy).not.to.be.called; 135 | 136 | await sleep(150); 137 | expect(spy).to.be.calledOnce; 138 | })); 139 | }); 140 | 141 | function viewportMetrics() { 142 | return listView.viewport.getMetrics(); 143 | } 144 | 145 | function checkViewportFillup() { 146 | const items = $('.test-container > .internal-viewport > ul > li'); 147 | const [rectFirst, rectLast] = [ 148 | items.first(), 149 | items.last(), 150 | ].map($el => $el.get(0).getBoundingClientRect()); 151 | const { top, bottom } = viewportMetrics().outer; 152 | 153 | if (listView.indexFirst > 0) { 154 | expect(rectFirst.top).to.be.at.most(top); 155 | } 156 | if (listView.indexLast < listView.options.items.length) { 157 | expect(rectLast.bottom).to.be.at.least(bottom); 158 | } 159 | 160 | return null; 161 | } 162 | 163 | function getElementRect(index) { 164 | expect(index).to.be.at.least(listView.indexFirst); 165 | expect(index).to.be.below(listView.indexLast); 166 | 167 | const el = $('.test-container > .internal-viewport > ul > li').get(index - listView.indexFirst); 168 | return el.getBoundingClientRect(); 169 | } 170 | 171 | function checkItemLocation(index, position) { 172 | const rect = getElementRect(index); 173 | const { top, bottom } = viewportMetrics().outer; 174 | const middle = (top + bottom) / 2; 175 | 176 | if (position === 'top') { 177 | expect(Math.abs(rect.top - top)).to.be.below(1); 178 | } else if (position === 'bottom') { 179 | expect(Math.abs(rect.bottom - bottom)).to.be.below(1); 180 | } else if (position === 'middle') { 181 | const elMiddle = (rect.top + rect.bottom) / 2; 182 | expect(Math.abs(elMiddle - middle)).to.be.below(1); 183 | } else if (_.isNumber(position)) { 184 | expect(Math.abs(rect.top - (top + position))).to.be.below(1); 185 | } 186 | } 187 | 188 | function checkScrolledToTop() { 189 | const scrollTop = listView.viewport.getMetrics().scroll.y; 190 | 191 | expect(Math.abs(scrollTop)).to.be.at.most(1); 192 | } 193 | 194 | function checkScrolledToBottom() { 195 | const metrics = viewportMetrics(); 196 | const scrollTopMax = metrics.inner.height - metrics.outer.height; 197 | const scrollTop = metrics.scroll.y; 198 | 199 | expect(scrollTop).to.be.at.least(scrollTopMax - 1); 200 | } 201 | 202 | function scrollToItem(...args) { 203 | return new Promise(resolve => listView.scrollToItem(...(args.concat([resolve])))); 204 | } 205 | 206 | function set(options) { 207 | return new Promise(resolve => listView.set(options, resolve)); 208 | } 209 | 210 | function getTestCases(viewFactory) { 211 | return function () { 212 | beforeEach(doAsync(async () => { 213 | listView = viewFactory({ size: 20000 }); 214 | await render(); 215 | listView.viewport.scrollTo({ y: 0 }); 216 | await sleep(redrawInterval); 217 | })); 218 | 219 | afterEach(doAsync(async () => { 220 | listView.remove(); 221 | await sleep(redrawInterval); 222 | })); 223 | 224 | it('should create the ListView correctly', function () { 225 | expect($('.test-container').get(0)).to.equal(listView.el); 226 | expect($('.test-container > .internal-viewport > ul > li').length).to.be.above(0); 227 | }); 228 | 229 | it('should fill up the viewport', function () { 230 | const elLast = $('.test-container > .internal-viewport > ul > li').last().get(0); 231 | const rectLast = elLast.getBoundingClientRect(); 232 | const height = viewportMetrics().outer.height; 233 | 234 | expect(rectLast.bottom).to.be.at.least(height); 235 | }); 236 | 237 | it('should fill up the viewport after jump scrolling', doAsync(async () => { 238 | for (let scrollTop of [1000, 2000, 20000, 10000]) { 239 | listView.viewport.scrollTo({ y: scrollTop }); 240 | await sleep(redrawInterval); 241 | 242 | checkViewportFillup(); 243 | } 244 | })); 245 | 246 | it('should fill up the viewport while scrolling down continuously', doAsync(async () => { 247 | for (let scrollTop = 1000; scrollTop < 1500; scrollTop += 100) { 248 | listView.viewport.scrollTo({ y: scrollTop }); 249 | await sleep(redrawInterval); 250 | 251 | checkViewportFillup(); 252 | } 253 | })); 254 | 255 | it('should fill up the viewport while scrolling up continuously', doAsync(async () => { 256 | for (let scrollTop = 2000; scrollTop > 1500; scrollTop -= 100) { 257 | listView.viewport.scrollTo({ y: scrollTop }); 258 | await sleep(redrawInterval); 259 | 260 | checkViewportFillup(); 261 | } 262 | })); 263 | 264 | it('should be able to scroll an element to top', doAsync(async () => { 265 | for (let index of [0, 1, 11, 111, 1111, 11111]) { 266 | await scrollToItem(index, 'top'); 267 | 268 | checkItemLocation(index, 'top'); 269 | checkViewportFillup(); 270 | } 271 | 272 | await scrollToItem(listView.options.items.length - 1, 'top'); 273 | 274 | checkScrolledToBottom(); 275 | checkViewportFillup(); 276 | })); 277 | 278 | it('should be able to scroll an element to bottom', doAsync(async () => { 279 | for (let index of [11111, 11110, 11100, 11000, 10000]) { 280 | await scrollToItem(index, 'bottom'); 281 | 282 | checkItemLocation(index, 'bottom'); 283 | checkViewportFillup(); 284 | } 285 | 286 | await scrollToItem(0, 'bottom'); 287 | 288 | checkScrolledToTop(); 289 | checkViewportFillup(); 290 | })); 291 | 292 | it('should be able to scroll an element to middle', doAsync(async () => { 293 | for (let index of [11111, 11110, 11100, 11000, 10000]) { 294 | await scrollToItem(index, 'middle'); 295 | 296 | checkItemLocation(index, 'middle'); 297 | checkViewportFillup(); 298 | } 299 | 300 | await scrollToItem(0, 'middle'); 301 | 302 | checkScrolledToTop(); 303 | checkViewportFillup(); 304 | 305 | await scrollToItem(listView.options.items.length - 1, 'middle'); 306 | 307 | checkScrolledToBottom(); 308 | checkViewportFillup(); 309 | })); 310 | 311 | it('should be able to scroll an element to certain offset', doAsync(async () => { 312 | const index = 1000; 313 | const height = viewportMetrics().outer.height; 314 | 315 | for (let pos of [0, 0.2, 0.5, 0.7, 0.9].map(rate => rate * height)) { 316 | await scrollToItem(index, pos); 317 | 318 | checkItemLocation(index, pos); 319 | checkViewportFillup(); 320 | } 321 | })); 322 | 323 | it('should be scroll item to nearest visible location with "default" option', doAsync(async () => { 324 | await scrollToItem(2000); 325 | checkItemLocation(2000, 'bottom'); 326 | 327 | await scrollToItem(2001); 328 | checkItemLocation(2001, 'bottom'); 329 | 330 | await scrollToItem(1000); 331 | checkItemLocation(1000, 'top'); 332 | 333 | await scrollToItem(999); 334 | checkItemLocation(999, 'top'); 335 | 336 | listView.scrollToItem(999); 337 | await sleep(redrawInterval); 338 | checkItemLocation(999, 'top'); 339 | 340 | const top = getElementRect(1000).top; 341 | await scrollToItem(1000); 342 | expect(Math.abs(getElementRect(1000).top - top)).to.be.below(1); 343 | })); 344 | 345 | it('should complain about wrong position opitons', function () { 346 | _.each([ 347 | true, 348 | 'some-where', 349 | { foo: 'bar' }, 350 | ['foo', 'bar'], 351 | ], pos => { 352 | expect(() => listView.scrollToItem(0, pos)).to.throw('Invalid position'); 353 | }); 354 | }); 355 | 356 | it('should complain about the view is not rendered', function () { 357 | const view = viewFactory({ size: 20000 }); 358 | const message = 'Cannot scroll before the view is rendered'; 359 | expect(() => view.scrollToItem(10)).to.throw(message); 360 | }); 361 | 362 | it('should be able to reset the defaultItemHeight', doAsync(async () => { 363 | const height = viewportMetrics().inner.height; 364 | await set({ defaultItemHeight: 22 }); 365 | expect(viewportMetrics().inner.height).to.be.above(height); 366 | })); 367 | 368 | it('should be able to reset the items', doAsync(async () => { 369 | const $ul = $('.test-container > .internal-viewport > ul'); 370 | const text = 'hello world!'; 371 | 372 | await set({ items: [{ text }] }); 373 | expect($ul.children().length).to.equal(3); 374 | expect($ul.children().text()).to.equal(text); 375 | 376 | await set({ items: [] }); 377 | expect($ul.length).to.equal(1); 378 | expect($ul.children().length).to.equal(2); 379 | })); 380 | 381 | it('should be able to use duck typed array as items', doAsync(async () => { 382 | const $ul = $('.test-container > .internal-viewport > ul'); 383 | const prefix = 'item'; 384 | 385 | await set({ 386 | items: { 387 | length: 50000, 388 | slice(start, stop) { 389 | return _.map(_.range(start, stop), i => ({ text: `${prefix} ${i}` })); 390 | }, 391 | }, 392 | }); 393 | expect($ul.children().first().next().text()).to.equal(`${prefix} 0`); 394 | checkViewportFillup(); 395 | })); 396 | 397 | it('should be able to reset the model and listTemplate', doAsync(async () => { 398 | const title = 'New Template'; 399 | const model = { title }; 400 | const listTemplate = alternativeListTemplate; 401 | 402 | await set({ model, listTemplate }); 403 | 404 | const $h2 = $('.test-container > h2'); 405 | expect($h2.length).to.equal(1); 406 | expect($h2.text()).to.equal(title); 407 | checkViewportFillup(); 408 | })); 409 | 410 | it('should be able to reset the itemTemplate', doAsync(async () => { 411 | const prefix = 'item'; 412 | const itemTemplate = ({ text }) => `
  • ${prefix} - ${text}
  • `; 413 | 414 | await set({ itemTemplate }); 415 | 416 | const $ul = $('.test-container > .internal-viewport > ul'); 417 | expect($ul.children().length).to.be.at.least(3); 418 | expect($ul.children().first().next().text()).to.be.equal(`${prefix} - ${listView.itemAt(0).text}`); 419 | checkViewportFillup(); 420 | })); 421 | 422 | it('should be able to handle the DOM events', doAsync(async () => { 423 | const spy = sinon.spy(); 424 | const events = { 'click li': spy }; 425 | 426 | await set({ events }); 427 | 428 | const $ul = $('.test-container > .internal-viewport > ul'); 429 | $ul.children().first().next().click(); 430 | expect(spy).to.be.calledOnce; 431 | })); 432 | 433 | it('should be able to handle the ListView events', doAsync(async () => { 434 | const spyWillRedraw = sinon.spy(); 435 | const spyDidRedraw = sinon.spy(); 436 | const events = { willRedraw: spyWillRedraw, didRedraw: spyDidRedraw }; 437 | 438 | await set({ events }); 439 | expect(spyWillRedraw).have.been.calledOnce; 440 | expect(spyDidRedraw).have.been.calledOnce; 441 | 442 | await scrollToItem(1000); 443 | expect(spyWillRedraw).have.been.calledTwice; 444 | expect(spyDidRedraw).have.been.calledTwice; 445 | 446 | await set({ events: {} }); 447 | expect(spyWillRedraw).have.been.calledTwice; 448 | expect(spyDidRedraw).have.been.calledTwice; 449 | 450 | await scrollToItem(0); 451 | expect(spyWillRedraw).have.been.calledTwice; 452 | expect(spyDidRedraw).have.been.calledTwice; 453 | })); 454 | 455 | it('should invoke the callback immediatedly if reset with no valid options', function () { 456 | const spy = sinon.spy(); 457 | 458 | listView.set({ foo: 'bar' }, spy); 459 | expect(spy).to.be.calledOnce; 460 | }); 461 | 462 | it('should be able to invalidate the rendered items', doAsync(async () => { 463 | const $ul = $('.test-container > .internal-viewport > ul'); 464 | const elFirst = $ul.children().get(1); 465 | 466 | await new Promise(resolve => listView.invalidate(resolve)); 467 | 468 | const elFirstNew = $ul.children().get(1); 469 | expect(elFirstNew).not.to.equal(elFirst); 470 | })); 471 | }; 472 | } 473 | 474 | describe('with WindowViewport', getTestCases(({ size }) => new ListView({ 475 | el: '.test-container', 476 | }).set({ 477 | listTemplate: initialListTemplate, 478 | items: _.map(_.range(size), i => ({ text: i })), 479 | }))); 480 | 481 | describe('with ElementViewport', getTestCases(({ size }) => { 482 | $('.test-container').css({ 483 | height: 600, 484 | width: 400, 485 | }); 486 | return new ListView({ 487 | el: '.test-container', 488 | }).set({ 489 | listTemplate: initialListTemplate, 490 | items: _.map(_.range(size), i => ({ text: i })), 491 | }); 492 | })); 493 | 494 | describe('with variant height items', getTestCases(({ size }) => { 495 | $('.test-container').css({ 496 | height: 500, 497 | width: 200, 498 | }); 499 | return new ListView({ 500 | el: '.test-container', 501 | }).set({ 502 | listTemplate: initialListTemplate, 503 | items: _.map(_.range(size), i => ({ 504 | text: `${i}: ${_.map(_.range(_.random(50)), () => _.random(9)).join('')}`, 505 | })), 506 | }); 507 | })); 508 | 509 | describe('with internal viewport', getTestCases(({ size }) => new ListView({ 510 | el: '.test-container', 511 | viewport: '.internal-viewport', 512 | }).set({ 513 | model: { title: 'Internal Viewport' }, 514 | listTemplate: initialListTemplate, 515 | items: _.map(_.range(size), i => ({ text: i })), 516 | }))); 517 | 518 | describe('Non-virtualized list view', function () { 519 | const count = 500; 520 | 521 | beforeEach(doAsync(async () => { 522 | listView = new ListView({ 523 | el: '.test-container', 524 | virtualized: false, 525 | }).set({ 526 | listTemplate: initialListTemplate, 527 | items: _.map(_.range(count), i => ({ text: i })), 528 | }); 529 | await render(); 530 | })); 531 | 532 | afterEach(doAsync(async () => { 533 | listView.remove(); 534 | await sleep(redrawInterval); 535 | })); 536 | 537 | async function checkDOMUnchanged(action) { 538 | const $ul = $('.test-container > .internal-viewport > ul'); 539 | const elFirst = $ul.children().first().get(0); 540 | const elLast = $ul.children().last().get(0); 541 | 542 | await action(function () { 543 | const $ulNew = $('.test-container > .internal-viewport > ul'); 544 | const elFirstNew = $ulNew.children().get(0); 545 | const elLastNew = $ulNew.children().last().get(0); 546 | 547 | expect($ulNew.get(0)).to.equal($ul.get(0)); 548 | expect(elFirstNew).to.equal(elFirst); 549 | expect(elLastNew).to.equal(elLast); 550 | }); 551 | } 552 | 553 | it('should render all items initially', function () { 554 | const $ul = $('.test-container > .internal-viewport > ul'); 555 | 556 | expect($ul.children().length).to.equal(count + 2); 557 | expect($ul.children().first().next().text()).to.equal('0'); 558 | }); 559 | 560 | it('should keep the DOM unchanged after scrolling', doAsync( 561 | async () => checkDOMUnchanged(async verify => { 562 | for (let scrollTop of [100, 1000, 5000, 3000]) { 563 | listView.viewport.scrollTo({ y: scrollTop }); 564 | await sleep(redrawInterval); 565 | verify(); 566 | } 567 | 568 | for (let scrollTop = 1000; scrollTop < 1500; scrollTop += 100) { 569 | listView.viewport.scrollTo({ y: scrollTop }); 570 | await sleep(redrawInterval); 571 | verify(); 572 | } 573 | 574 | for (let scrollTop = 2000; scrollTop > 1500; scrollTop -= 100) { 575 | listView.viewport.scrollTo({ y: scrollTop }); 576 | await sleep(redrawInterval); 577 | verify(); 578 | } 579 | }) 580 | )); 581 | 582 | it('should keep the DOM unchanged after scrolling to item', doAsync( 583 | async () => checkDOMUnchanged(async verify => { 584 | for (let index of [0, 1, 11, 111]) { 585 | await scrollToItem(index, 'top'); 586 | verify(); 587 | checkItemLocation(index, 'top'); 588 | } 589 | 590 | for (let index of [111, 110, 100]) { 591 | await scrollToItem(index, 'bottom'); 592 | verify(); 593 | checkItemLocation(index, 'bottom'); 594 | } 595 | 596 | for (let index of [111, 110, 100]) { 597 | await scrollToItem(index, 'middle'); 598 | verify(); 599 | checkItemLocation(index, 'middle'); 600 | } 601 | 602 | await scrollToItem(0, 'bottom'); 603 | verify(); 604 | checkScrolledToTop(); 605 | 606 | await scrollToItem(listView.length - 1, 'top'); 607 | verify(); 608 | checkScrolledToBottom(); 609 | 610 | await scrollToItem(0, 'middle'); 611 | verify(); 612 | checkScrolledToTop(); 613 | 614 | await scrollToItem(listView.length - 1, 'middle'); 615 | verify(); 616 | checkScrolledToBottom(); 617 | }) 618 | )); 619 | }); 620 | }); 621 | -------------------------------------------------------------------------------- /spec/initial-list.jade: -------------------------------------------------------------------------------- 1 | .internal-viewport(style={ height: '500px' }) 2 | ul.list-container 3 | .top-filler 4 | .bottom-filler 5 | -------------------------------------------------------------------------------- /spec/internal-viewport-list.jade: -------------------------------------------------------------------------------- 1 | h2=title 2 | .viewport(style={ height: '600px' }) 3 | ul 4 | .top-filler 5 | .bottom-filler 6 | 7 | -------------------------------------------------------------------------------- /spec/test-container.jade: -------------------------------------------------------------------------------- 1 | h1=title 2 | .test-container 3 | -------------------------------------------------------------------------------- /spec/test-util.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | 3 | export function sleep(timeInterval) { 4 | return new Promise(resolve => { 5 | window.setTimeout(() => resolve(), timeInterval); 6 | }); 7 | } 8 | 9 | export function doAsync(fn) { 10 | return async function (done) { 11 | try { 12 | await fn(this); 13 | done(); 14 | } catch (err) { 15 | done(err); 16 | } 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /spec/viewport-detection-element.jade: -------------------------------------------------------------------------------- 1 | .viewport-outer(style={ overflow: 'scroll' }) 2 | .viewport-raw 3 | .viewport-inner(style={ overflow: 'auto' }) 4 | .test-container 5 | -------------------------------------------------------------------------------- /spec/viewport-detection-window.jade: -------------------------------------------------------------------------------- 1 | .viewport-raw 2 | .test-container 3 | -------------------------------------------------------------------------------- /spec/viewport-detection.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | import chai from 'chai'; 3 | import ListView from '../js/index.js'; 4 | import elementViewportTemplate from './viewport-detection-element.jade'; 5 | import windowViewportTemplate from './viewport-detection-window.jade'; 6 | import listTemplate from './initial-list.jade'; 7 | import { doAsync } from './test-util.js'; 8 | import { WindowViewport, ElementViewport } from '../js/viewport.js'; 9 | 10 | const expect = chai.expect; 11 | 12 | describe('ListView', function () { 13 | let listView = null; 14 | 15 | afterEach(function () { 16 | if (listView) { 17 | listView.remove(); 18 | } 19 | $('body').empty(); 20 | }); 21 | 22 | describe('viewport detection', function () { 23 | it('should use a WindowViewport when giving a window', doAsync(async () => { 24 | $('body').html(elementViewportTemplate()); 25 | 26 | listView = new ListView({ 27 | el: '.test-container', 28 | viewport: window, 29 | }).set({ items: [] }); 30 | 31 | await new Promise(resolve => listView.render(resolve)); 32 | 33 | expect(listView.viewport).to.be.instanceof(WindowViewport); 34 | })); 35 | 36 | it('should use the ElementViewport when giving the jQuery object', doAsync(async () => { 37 | $('body').html(elementViewportTemplate()); 38 | 39 | listView = new ListView({ 40 | el: '.test-container', 41 | viewport: $('.viewport-raw'), 42 | }).set({ items: [] }); 43 | 44 | await new Promise(resolve => listView.render(resolve)); 45 | 46 | expect(listView.viewport).to.be.instanceof(ElementViewport); 47 | expect(listView.viewport.el).to.equal($('.viewport-raw').get(0)); 48 | expect(listView.viewport.$el.css('overflow')).to.equal('auto'); 49 | })); 50 | 51 | it('should use the ElementViewport when giving the DOM element', doAsync(async () => { 52 | $('body').html(elementViewportTemplate()); 53 | 54 | listView = new ListView({ 55 | el: '.test-container', 56 | viewport: $('.viewport-raw').get(0), 57 | }).set({ items: [] }); 58 | 59 | await new Promise(resolve => listView.render(resolve)); 60 | 61 | expect(listView.viewport).to.be.instanceof(ElementViewport); 62 | expect(listView.viewport.el).to.equal($('.viewport-raw').get(0)); 63 | expect(listView.viewport.$el.css('overflow')).to.equal('auto'); 64 | })); 65 | 66 | it('should use the ElementViewport with the inner element when giving a selector', doAsync(async () => { 67 | $('body').html(elementViewportTemplate()); 68 | 69 | listView = new ListView({ 70 | el: '.test-container', 71 | viewport: '.internal-viewport', 72 | }).set({ 73 | items: [], 74 | listTemplate, 75 | }); 76 | 77 | await new Promise(resolve => listView.render(resolve)); 78 | 79 | expect(listView.viewport).to.be.instanceof(ElementViewport); 80 | expect(listView.viewport.el).to.equal(listView.$('.internal-viewport').get(0)); 81 | expect(listView.viewport.$el.css('overflow')).to.equal('auto'); 82 | })); 83 | 84 | it('should detect the nearest ElementViewport when not specified', doAsync(async () => { 85 | $('body').html(elementViewportTemplate()); 86 | 87 | listView = new ListView({ 88 | el: '.test-container', 89 | }).set({ items: [] }); 90 | 91 | await new Promise(resolve => listView.render(resolve)); 92 | 93 | expect(listView.viewport).to.be.instanceof(ElementViewport); 94 | expect(listView.viewport.el).to.equal($('.viewport-inner').get(0)); 95 | })); 96 | 97 | it('should use a WindowViewport if no element viewport detected', doAsync(async () => { 98 | $('body').html(windowViewportTemplate()); 99 | 100 | listView = new ListView({ 101 | el: '.test-container', 102 | }).set({ items: [] }); 103 | 104 | await new Promise(resolve => listView.render(resolve)); 105 | 106 | expect(listView.viewport).to.be.instanceof(WindowViewport); 107 | })); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /spec/viewport.js: -------------------------------------------------------------------------------- 1 | import _ from 'underscore'; 2 | import $ from 'jquery'; 3 | import chai from 'chai'; 4 | import sinon from 'sinon'; 5 | import sinonChai from 'sinon-chai'; 6 | 7 | import { Viewport } from '../js/viewport.js'; 8 | import { doAsync, sleep } from './test-util.js'; 9 | 10 | chai.use(sinonChai); 11 | 12 | const expect = chai.expect; 13 | 14 | describe('Viewport', function () { 15 | let viewport = null; 16 | beforeEach(function () { 17 | viewport = new Viewport($(window)); 18 | }); 19 | 20 | describe('onScroll', function () { 21 | it('should trigger the change event', function () { 22 | let spy = sinon.spy(); 23 | 24 | viewport.on('change', spy); 25 | viewport.onScroll(); 26 | expect(spy).to.be.calledOnce; 27 | }); 28 | 29 | it('should trigger the scroll event', function () { 30 | let spy = sinon.spy(); 31 | 32 | viewport.on('scroll', spy); 33 | viewport.onScroll(); 34 | expect(spy).to.be.calledOnce; 35 | }); 36 | }); 37 | 38 | describe('onResize', function () { 39 | it('should trigger the change event', function () { 40 | let spy = sinon.spy(); 41 | 42 | viewport.on('change', spy); 43 | viewport.onResize(); 44 | expect(spy).to.be.calledOnce; 45 | }); 46 | 47 | it('should trigger the resize event', function () { 48 | let spy = sinon.spy(); 49 | 50 | viewport.on('resize', spy); 51 | viewport.onResize(); 52 | expect(spy).to.be.calledOnce; 53 | }); 54 | }); 55 | 56 | describe('key event handlers', function () { 57 | it('should trigger key press on keydown', function () { 58 | let spy = sinon.spy(); 59 | 60 | viewport.on('keypress', spy); 61 | viewport.onKeydown({ keyCode: 36 }); 62 | expect(spy).to.be.calledOnce; 63 | }); 64 | 65 | it('should consolidate the dead keys', function () { 66 | let spy = sinon.spy(); 67 | 68 | viewport.on('keypress', spy); 69 | _.times(10, () => viewport.onKeydown({ keyCode: 36 })); 70 | expect(spy).to.be.calledOnce; 71 | }); 72 | 73 | it('should respect the keyup when detecting dead keys', function () { 74 | let spy = sinon.spy(); 75 | 76 | viewport.on('keypress', spy); 77 | _.times(10, () => { 78 | viewport.onKeydown({ keyCode: 36 }); 79 | viewport.onKeyup(); 80 | }); 81 | expect(spy).to.have.callCount(10); 82 | }); 83 | 84 | it('should release the dead key after a 0.2 second interval', doAsync(async () => { 85 | let spy = sinon.spy(); 86 | 87 | viewport.on('keypress', spy); 88 | viewport.onKeydown({ keyCode: 36 }); 89 | 90 | await sleep(250); 91 | 92 | viewport.onKeydown({ keyCode: 36 }); 93 | expect(spy).to.be.calledTwice; 94 | })); 95 | 96 | it('should treat different keys as separated events', function () { 97 | let spy = sinon.spy(); 98 | 99 | viewport.on('keypress', spy); 100 | _.each(_.range(10), i => viewport.onKeydown({ keyCode: 36 + i })); 101 | expect(spy).to.have.callCount(10); 102 | }); 103 | }); 104 | 105 | describe('getMetrics', function () { 106 | it('should complain about "not implemented"', function () { 107 | expect(() => viewport.getMetrics()).to.throw('Not implemented'); 108 | }); 109 | }); 110 | 111 | describe('scrollTo', function () { 112 | it('should try to set the scrollTop and scrollLeft of $el', function () { 113 | const left = 10; 114 | const top = 20; 115 | const spyLeft = viewport.$el.scrollLeft = sinon.spy(); 116 | const spyTop = viewport.$el.scrollTop = sinon.spy(); 117 | 118 | viewport.scrollTo({ x: left, y: top }); 119 | expect(spyLeft).to.be.calledWith(left); 120 | expect(spyTop).to.be.calledWith(top); 121 | }); 122 | }); 123 | }); 124 | 125 | -------------------------------------------------------------------------------- /speclist.js: -------------------------------------------------------------------------------- 1 | require('babel-polyfill'); 2 | var testsContext = require.context('./spec', true, /\.js$/); 3 | testsContext.keys().forEach(testsContext); 4 | 5 | //require('./spec/viewport-detection.js'); 6 | //require('./spec/index.js'); 7 | -------------------------------------------------------------------------------- /wdio.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var democase = require('democase'); 3 | 4 | var demoSet = democase.loadSync(path.resolve(__dirname, 'demos')); 5 | var config = demoSet.wdioConfig({ 6 | capabilities: [{ 7 | browserName: 'phantomjs', 8 | }], 9 | reporters: ['dot', 'junit'], 10 | reporterOptions: { outputDir: './test-results/' }, 11 | }); 12 | 13 | module.exports = { config: config }; 14 | -------------------------------------------------------------------------------- /webpack.alias.js: -------------------------------------------------------------------------------- 1 | // Config your webpack resolve.alias in this file 2 | module.exports = {}; 3 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var _ = require('underscore'); 2 | var path = require('path'); 3 | var pkg = require('./package'); 4 | 5 | var webpackAlias = pkg.webpackAlias || {}; 6 | 7 | try { 8 | webpackAlias = require('./webpack.alias'); 9 | } catch (e) { } 10 | 11 | function getExternals() { 12 | var deps = _.keys(pkg.peerDependencies); 13 | var externals = _.object(deps, deps); 14 | 15 | return _.reduce(_.pairs(webpackAlias), function (exts, pair) { 16 | if (_.has(externals, pair[1])) { 17 | exts[pair[0]] = pair[1]; 18 | } 19 | return exts; 20 | }, externals); 21 | } 22 | 23 | module.exports = { 24 | entry: path.resolve('./js/index.js'), 25 | output: { 26 | path: path.join(__dirname, 'dist'), 27 | filename: 'backbone-virtualized-listview.js', 28 | library: 'backbone-virtualized-listview', 29 | libraryTarget: 'umd', 30 | umdNamedDefine: false, 31 | devtoolModuleFilenameTemplate: function (info) { 32 | if (path.isAbsolute(info.absoluteResourcePath)) { 33 | return 'webpack-src:///backbone-virtualized-listview/' + path.relative('.', info.absoluteResourcePath); 34 | } 35 | return info.absoluteResourcePath; 36 | }, 37 | }, 38 | module: { 39 | loaders: [ 40 | // jade 41 | { test: /\.jade$/, loader: 'jade-loader' }, 42 | // jade-end 43 | // es2015 44 | { test: /\.js$/, exclude: /\bnode_modules\b/, loader: 'babel-loader' }, 45 | // es2015-end 46 | // react 47 | { test: /\.less$/, loader: 'style!css!less' }, 48 | ], 49 | }, 50 | babel: { presets: ['es2015', 'stage-3'] }, 51 | externals: [getExternals()], 52 | resolve: { alias: webpackAlias }, 53 | devtool: 'source-map', 54 | }; 55 | --------------------------------------------------------------------------------