├── .npmignore
├── .babelrc
├── index.js
├── .gitignore
├── .eslintrc
├── test
├── README.md
├── context-test.js
├── recursive-component-test.js
├── specificity-test.js
├── containment-test.js
├── exit-test.js
├── conditional-rendering-test.js
├── index-test.js
├── nesting-test.js
├── recursive-exit-test.js
├── key-function-test.js
└── basics-test.js
├── rollup.config.js
├── CONTRIBUTING.md
├── package.json
├── LICENSE
├── src
└── component.js
└── README.md
/.npmignore:
--------------------------------------------------------------------------------
1 | build/*.zip
2 | test/
3 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [ "es2015" ]
3 | }
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | export {default as component} from "./src/component";
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | build/
3 | node_modules
4 | npm-debug.log
5 | *.swp
6 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb-base",
3 | "rules": {
4 | "no-param-reassign": "off",
5 | "no-use-before-define": "off"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/test/README.md:
--------------------------------------------------------------------------------
1 | These tests use [Tape](https://github.com/substack/tape), and are written to match the structure of tests in [other D3 repositories](https://github.com/d3). These tests were originally scaffolded out by following instructions in [Let’s Make a (D3) Plugin](https://bost.ocks.org/mike/d3-plugin/).
2 |
3 | To run these tests, invoke the `test` script defined in `package.json` by running:
4 |
5 | `npm test`
6 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import babel from 'rollup-plugin-babel';
2 | import babelrc from 'babelrc-rollup';
3 |
4 | let pkg = require('./package.json');
5 | let external = Object.keys(pkg.dependencies);
6 |
7 | export default {
8 | input: 'index.js',
9 | plugins: [
10 | babel(babelrc()),
11 | ],
12 | external: external,
13 | output: [
14 | {
15 | file: pkg.main,
16 | format: 'umd',
17 | name: 'd3',
18 | sourcemap: true
19 | }
20 | ],
21 | globals: {
22 | 'd3-selection': 'd3'
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to contribute
2 |
3 | Any feedback, questions, bug reports, and general discussions are most welcome.
4 |
5 | Please [submit an issue](https://github.com/curran/d3-component/issues/new) to start a discussion.
6 |
7 | If you've created something using d3-component, we'd love to hear about it! Please [submit an issue](https://github.com/curran/d3-component/issues/new) to request adding a link in the README to your example.
8 |
9 | To get started with development,
10 |
11 | * clone the repository,
12 | * run `npm install`, then
13 | * run `npm test`, which passes the source code `src/component.js` through ESLint and runs the tests found under `test`.
14 |
--------------------------------------------------------------------------------
/test/context-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_selection = require("d3-selection"),
4 | d3_component = require("../"),
5 | d3 = Object.assign(d3_selection, d3_component);
6 |
7 | var paragraph = d3.component("p")
8 | .render(function (selection, d){
9 | selection.text(d.text);
10 | d.callback();
11 | });
12 |
13 | tape("A component should accept a context.", function(test) {
14 | var div = d3.select(jsdom.jsdom().body).append("div");
15 |
16 | var data = [
17 | { text: "foo" },
18 | { text: "bar" }
19 | ];
20 |
21 | var count = 0;
22 | var context = {
23 | callback: function (){
24 | count++;
25 | }
26 | };
27 |
28 | // Multiple instances
29 | div.call(paragraph, data, context);
30 | test.equal(div.html(), "
foo
bar
");
31 | test.equal(count, 2);
32 |
33 | // Single instance
34 | div.call(paragraph, data[0], context);
35 | test.equal(div.html(), "foo
");
36 | test.equal(count, 3);
37 |
38 | // Data properties override context properties.
39 | context.text = "pwn";
40 | div.call(paragraph, data[0], context);
41 | test.equal(div.html(), "foo
");
42 | test.equal(count, 4);
43 |
44 | test.end();
45 |
46 | });
47 |
48 |
--------------------------------------------------------------------------------
/test/recursive-component-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3 = Object.assign(require("../"), require("d3-selection"));
4 |
5 |
6 | var recursiveComponent = d3.component("div")
7 | .render(function (selection, d){
8 | selection
9 | .attr("class", d.class)
10 | .call(recursiveComponent, d.children || []);
11 | });
12 |
13 |
14 | tape("Recursive component.", function(test) {
15 | var div = d3.select(jsdom.jsdom().body).append("div");
16 |
17 | div.call(recursiveComponent, { class: "a" });
18 | test.equal(div.html(), '');
19 |
20 | div.call(recursiveComponent, {
21 | class: "a",
22 | children: [{ class: "b" }]
23 | });
24 | test.equal(div.html(), '');
25 |
26 | div.call(recursiveComponent, {
27 | class: "a",
28 | children: [
29 | {
30 | class: "b",
31 | children: [
32 | { class: "c" }
33 | ]
34 | },
35 | { class: "d" }
36 | ]
37 | });
38 | test.equal(div.html(), [
39 | ''
44 | ].join(""));
45 |
46 | test.end();
47 | });
48 |
--------------------------------------------------------------------------------
/test/specificity-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_selection = require("d3-selection"),
4 | d3_component = require("../"),
5 | d3 = Object.assign(d3_selection, d3_component);
6 |
7 |
8 | var paragraphA = d3.component("p", "some-class")
9 | .render(function (selection){
10 | selection.text("A");
11 | }),
12 | paragraphB = d3.component("p", "some-class")
13 | .render(function (selection){
14 | selection.text("B");
15 | });
16 |
17 |
18 | tape("Components with the same tag and class should be able to coexist as DOM siblings.", function(test) {
19 | var div = d3.select(jsdom.jsdom().body).append("div")
20 | .call(paragraphA)
21 | .call(paragraphB);
22 | test.equal(div.html(), 'A
B
');
23 | test.end();
24 | });
25 |
26 | tape("Components should coexist with non-component DOM siblings with the same tag and class.", function(test) {
27 | var div = d3.select(jsdom.jsdom().body).append("div");
28 | div.append("p").attr("class", "some-class").text("Non-component node");
29 | div.call(paragraphA);
30 | test.equal(div.html(), 'Non-component node
A
');
31 | test.end();
32 | });
33 |
--------------------------------------------------------------------------------
/test/containment-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_selection = require("d3-selection"),
4 | d3_component = require("../"),
5 | d3 = Object.assign(d3_selection, d3_component),
6 | post = require("./nesting-test").post;
7 |
8 |
9 | var card = d3.component("div", "card")
10 | .create(function (selection){
11 | selection
12 | .append("div")
13 | .attr("class", "card-block")
14 | .append("div")
15 | .attr("class", "card-text");
16 | })
17 | .render(function (selection, d){
18 | selection
19 | .select(".card-text")
20 | .call(d.childComponent, d.childProps);
21 | });
22 |
23 |
24 | tape("Containment.", function(test) {
25 | var div = d3.select(jsdom.jsdom().body).append("div");
26 | div.call(card, {
27 | childComponent: post,
28 | childProps: [
29 | { title: "A Title", content: "a content" },
30 | { title: "B Title", content: "b content" },
31 | ]
32 | });
33 | test.equal(div.html(), [
34 | '',
35 | '
',
36 | '
',
37 | '
',
38 | '
',
39 | "
",
40 | "
",
41 | "
"
42 | ].join(""));
43 |
44 | test.end();
45 | });
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "d3-component",
3 | "version": "3.1.0",
4 | "description": "A D3 component system.",
5 | "keywords": [
6 | "d3",
7 | "d3-module"
8 | ],
9 | "license": "BSD-3-Clause",
10 | "main": "build/d3-component.js",
11 | "jsnext:main": "index",
12 | "module": "index",
13 | "homepage": "https://github.com/curran/d3-component",
14 | "repository": {
15 | "type": "git",
16 | "url": "https://github.com/curran/d3-component.git"
17 | },
18 | "scripts": {
19 | "lint": "eslint src",
20 | "lint:fix": "eslint src --fix",
21 | "pretest": "npm run lint && rm -rf build && mkdir build && rollup -c",
22 | "test": "tape 'test/**/*-test.js'",
23 | "prepublish": "npm run test && uglifyjs build/d3-component.js -c -m -o build/d3-component.min.js",
24 | "postpublish": "git push; git push --tags"
25 | },
26 | "devDependencies": {
27 | "babel-plugin-external-helpers": "^6.22.0",
28 | "babel-preset-es2015": "^6.24.1",
29 | "babel-register": "^6.26.0",
30 | "babelrc-rollup": "^3.0.0",
31 | "d3-transition": "^1.1.1",
32 | "eslint": "^4.13.1",
33 | "eslint-config-airbnb-base": "^12.1.0",
34 | "eslint-plugin-import": "^2.8.0",
35 | "jsdom": "^9.11.0",
36 | "rollup": "0.52",
37 | "rollup-plugin-babel": "^3.0.2",
38 | "tape": "4",
39 | "uglify-js": "3"
40 | },
41 | "dependencies": {
42 | "d3-selection": "^1.2.0"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/test/exit-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_transition = require("d3-transition"),
4 | d3_selection = require("d3-selection"),
5 | d3_component = require("../"),
6 | d3 = Object.assign(d3_selection, d3_component);
7 |
8 | var datum,
9 | customExit = d3.component("p")
10 | .destroy(function (selection, d){
11 | datum = d;
12 | return selection.transition().duration(10);
13 | });
14 |
15 | tape("A component should be able to specify custom destroy transitions.", function(test) {
16 | var div = d3.select(jsdom.jsdom().body).append("div");
17 |
18 | div.call(customExit);
19 | test.equal(div.html(), "");
20 |
21 | div.call(customExit, []);
22 |
23 | // The transition is happening, so DOM element not removed yet.
24 | test.equal(div.html(), "");
25 |
26 | // DOM element removed after transition ends.
27 | setTimeout(function (){
28 | test.equal(div.html(), "");
29 | test.end();
30 | }, 30); // The transition lasts 10 ms, so it should be done after 30.
31 | });
32 |
33 | tape("Datum passed to destroy should be most recent.", function(test) {
34 | var div = d3.select(jsdom.jsdom().body).append("div");
35 |
36 | div.call(customExit, "a");
37 | div.call(customExit, []);
38 | test.equal(datum, "a");
39 |
40 | div.call(customExit, "a");
41 | div.call(customExit, "b");
42 | div.call(customExit, []);
43 | test.equal(datum, "b"); // Fails here, uses "a"
44 |
45 | test.end();
46 | });
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2017, Curran Kelleher
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice, this
8 | list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | * Neither the name of the author nor the names of contributors may be used to
15 | endorse or promote products derived from this software without specific prior
16 | written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/test/conditional-rendering-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_selection = require("d3-selection"),
4 | d3_component = require("../"),
5 | d3 = Object.assign(d3_selection, d3_component),
6 | post = require("./nesting-test").post;
7 |
8 |
9 | var apple = d3.component("span", "apple"),
10 | orange = d3.component("span", "orange"),
11 |
12 | // One of the limitations of this library
13 | // is that you can't have apples and oranges mixed as peers
14 | // with a data-driven ordering, so you need to introduce
15 | // an intermediate "switcher" component, like this fruit component here.
16 | fruit = d3.component("div")
17 | .render(function (selection, d){
18 | selection
19 | .call(apple, d === "apple" || []) // If type matches, pass true as datum, else pass [].
20 | .call(orange, d === "orange" || [])
21 | });
22 |
23 |
24 | tape("Conditional rendering.", function(test) {
25 | var div = d3.select(jsdom.jsdom().body).append("div");
26 |
27 | // Enter
28 | div.call(fruit, [ "apple", "orange", "apple", "apple", "orange" ]);
29 | test.equal(div.html(), [
30 | '
',
31 | '
',
32 | '
',
33 | '
',
34 | '
'
35 | ].join(""));
36 |
37 | // Update + Exit
38 | div.call(fruit, [ "orange", "apple", "apple" ]);
39 | test.equal(div.html(), [
40 | '
',
41 | '
',
42 | '
'
43 | ].join(""));
44 |
45 | // Exit
46 | div.call(fruit, []);
47 | test.equal(div.html(), "");
48 |
49 | test.end();
50 | });
51 |
--------------------------------------------------------------------------------
/test/index-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_selection = require("d3-selection"),
4 | d3_component = require("../"),
5 | d3 = Object.assign(d3_selection, d3_component);
6 |
7 | var createArgs = [],
8 | renderArgs = [],
9 | destroyArgs = [],
10 | argsTester = d3.component("div")
11 | .create(function () {
12 | createArgs.push(arguments);
13 | })
14 | .render(function () {
15 | renderArgs.push(arguments);
16 | })
17 | .destroy(function (selection, datum, index) {
18 | destroyArgs.push(arguments);
19 | });
20 |
21 | tape("Multiple instances should have correct indices.", function(test) {
22 | createArgs = [];
23 | renderArgs = [];
24 | destroyArgs = [];
25 |
26 | var div = d3.select(jsdom.jsdom().body).append("div");
27 | div.call(argsTester, ["one", "two", "three"]);
28 |
29 | test.equal(createArgs.length, 3);
30 | test.equal(createArgs[0][2], 0);
31 | test.equal(createArgs[1][2], 1);
32 | test.equal(createArgs[2][2], 2);
33 |
34 | test.equal(renderArgs.length, 3);
35 | test.equal(renderArgs[0][2], 0);
36 | test.equal(renderArgs[1][2], 1);
37 | test.equal(renderArgs[2][2], 2);
38 |
39 | div.call(argsTester, []);
40 | test.equal(destroyArgs.length, 3);
41 | test.equal(destroyArgs[0][2], 0);
42 | test.equal(destroyArgs[1][2], 1);
43 | test.equal(destroyArgs[2][2], 2);
44 |
45 | test.end();
46 | });
47 |
48 | tape("Single instances should have index 0.", function(test) {
49 | createArgs = [];
50 | renderArgs = [];
51 | destroyArgs = [];
52 |
53 | var div = d3.select(jsdom.jsdom().body).append("div");
54 | div.call(argsTester, "one");
55 |
56 | test.equal(createArgs.length, 1);
57 | test.equal(createArgs[0][2], 0);
58 |
59 | test.equal(renderArgs.length, 1);
60 | test.equal(renderArgs[0][2], 0);
61 |
62 | div.call(argsTester, []);
63 | test.equal(destroyArgs.length, 1);
64 |
65 | test.end();
66 | });
67 |
--------------------------------------------------------------------------------
/test/nesting-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_selection = require("d3-selection"),
4 | d3_component = require("../"),
5 | d3 = Object.assign(d3_selection, d3_component);
6 |
7 |
8 | var heading = d3.component("h1")
9 | .render(function (selection, d){
10 | selection.text(d);
11 | }),
12 | paragraph = d3.component("p")
13 | .render(function (selection, d){
14 | selection.text(d);
15 | }),
16 | post = d3.component("div", "post")
17 | .render(function (selection, d){
18 | selection
19 | .call(heading, d.title)
20 | .call(paragraph, d.content);
21 | });
22 |
23 |
24 | tape("Nesting single instance.", function(test) {
25 | var div = d3.select(jsdom.jsdom().body).append("div");
26 |
27 | div.call(post, {
28 | title: "Title",
29 | content: "Content here."
30 | });
31 |
32 | test.equal(div.html(), [
33 | '',
34 | '
Title
',
35 | '
Content here.
',
36 | '
'
37 | ].join(""));
38 |
39 | test.end();
40 | });
41 |
42 | tape("Nesting multiple instances.", function(test) {
43 | var div = d3.select(jsdom.jsdom().body).append("div");
44 |
45 | // Enter
46 | div.call(post, [
47 | { title: "A", content: "a" },
48 | { title: "B", content: "b" },
49 | ]);
50 | test.equal(div.html(), [
51 | '',
52 | ''
53 | ].join(""));
54 |
55 | // Enter + Update
56 | div.call(post, [
57 | { title: "D", content: "d" },
58 | { title: "E", content: "e" },
59 | { title: "F", content: "f" },
60 | ]);
61 | test.equal(div.html(), [
62 | '',
63 | '',
64 | ''
65 | ].join(""));
66 |
67 | // Exit
68 | div.call(post, []);
69 | test.equal(div.html(), "");
70 |
71 | test.end();
72 | });
73 |
74 |
75 | module.exports = {
76 | post: post
77 | };
78 |
--------------------------------------------------------------------------------
/test/recursive-exit-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_selection = require("d3-selection"),
4 | d3_component = require("../"),
5 | d3 = Object.assign(d3_selection, d3_component);
6 |
7 |
8 | var leafDestroyed = 0,
9 | leaf = d3.component("div", "leaf")
10 | .destroy(function (){
11 | leafDestroyed ++;
12 | })
13 | twig = d3.component("div", "twig")
14 | .render(function (selection){
15 | selection.call(leaf);
16 | });
17 | branch = d3.component("div", "branch")
18 | .render(function (selection){
19 | selection.call(twig, [1, 2]);
20 | }),
21 | treeDestroyed = 0,
22 | tree = d3.component("div", "tree")
23 | .create(function (selection){
24 | selection
25 | .append("div")
26 | .attr("class", "trunk");
27 | })
28 | .render(function (selection){
29 | selection
30 | .select(".trunk")
31 | .call(branch, [1, 2, 3]);
32 | })
33 | .destroy(function (){
34 | treeDestroyed++;
35 | });
36 |
37 |
38 | tape("Recursive destroy.", function(test) {
39 | var div = d3.select(jsdom.jsdom().body).append("div");
40 |
41 | div.call(tree);
42 | test.equal(div.html(), [
43 | '',
44 | '
',
45 | '
',
46 | '
',
49 | '
',
52 | '
',
53 | '
',
54 | '
',
57 | '
',
60 | '
',
61 | '
',
62 | '
',
65 | '
',
68 | '
',
69 | '
',
70 | '
',
71 | ].join(""));
72 | test.equal(leafDestroyed, 0);
73 |
74 | div.call(tree, []);
75 | test.equal(leafDestroyed, 6);
76 | test.equal(treeDestroyed, 1);
77 |
78 | test.end();
79 | });
80 |
--------------------------------------------------------------------------------
/test/key-function-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_selection = require("d3-selection"),
4 | d3_component = require("../"),
5 | d3 = Object.assign(d3_selection, d3_component);
6 |
7 |
8 | var created,
9 | destroyed,
10 | apple = d3.component("span", "apple")
11 | .create(function (){ created++; })
12 | .destroy(function(){ destroyed++; }),
13 | orange = d3.component("span", "orange")
14 | .create(function (){ created++; })
15 | .destroy(function(){ destroyed++; }),
16 | renderFruit = function (selection, d){
17 | selection
18 | .call(apple, d.type === "apple" ? true : [])
19 | .call(orange, d.type === "orange" ? true : []);
20 | },
21 | fruitNotKeyed = d3.component("div", "fruit")
22 | .render(renderFruit),
23 | fruitKeyed = d3.component("div", "fruit")
24 | .render(renderFruit)
25 | .key(function (d){ return d.id; });
26 |
27 | tape("Use index as key if key function not specified.", function(test) {
28 | var div = d3.select(jsdom.jsdom().body).append("div");
29 |
30 | // Enter.
31 | created = destroyed = 0;
32 | div.call(fruitNotKeyed, [
33 | { id: "a", type: "apple"},
34 | { id: "b", type: "orange"}
35 | ]);
36 | test.equal(created, 2);
37 | test.equal(destroyed, 0);
38 |
39 | // Update with swap (unnecessary creation and destruction).
40 | created = destroyed = 0;
41 | div.call(fruitNotKeyed, [
42 | { id: "b", type: "orange"},
43 | { id: "a", type: "apple"}
44 | ]);
45 | test.equal(created, 2);
46 | test.equal(destroyed, 2);
47 |
48 | // Exit (tests recursive destruction).
49 | created = destroyed = 0;
50 | div.call(fruitNotKeyed, []);
51 | test.equal(created, 0);
52 | test.equal(destroyed, 2);
53 |
54 | test.end();
55 | });
56 |
57 | tape("Use key function if specified.", function(test) {
58 | var div = d3.select(jsdom.jsdom().body).append("div");
59 |
60 | // Enter.
61 | created = destroyed = 0;
62 | div.call(fruitKeyed, [
63 | { id: "a", type: "apple"},
64 | { id: "b", type: "orange"}
65 | ]);
66 | test.equal(created, 2);
67 | test.equal(destroyed, 0);
68 |
69 | // Update with swap (no unnecessary creation and destruction).
70 | created = destroyed = 0;
71 | div.call(fruitKeyed, [
72 | { id: "b", type: "orange"},
73 | { id: "a", type: "apple"}
74 | ]);
75 | test.equal(created, 0);
76 | test.equal(destroyed, 0);
77 |
78 | // Exit (tests recursive destruction).
79 | created = destroyed = 0;
80 | div.call(fruitKeyed, []);
81 | test.equal(created, 0);
82 | test.equal(destroyed, 2);
83 |
84 | test.end();
85 | });
86 |
--------------------------------------------------------------------------------
/test/basics-test.js:
--------------------------------------------------------------------------------
1 | var tape = require("tape"),
2 | jsdom = require("jsdom"),
3 | d3_selection = require("d3-selection"),
4 | d3_component = require("../"),
5 | d3 = Object.assign(d3_selection, d3_component);
6 |
7 |
8 | var paragraphDatum,
9 | paragraph = d3.component("p")
10 | .render(function (selection, d){
11 | paragraphDatum = d;
12 | selection.text(d);
13 | });
14 |
15 | var createArgs,
16 | renderArgs,
17 | destroyArgs,
18 | argsTester = d3.component("div")
19 | .create(function (selection, d){
20 | createArgs = arguments;
21 | })
22 | .render(function (selection, d){
23 | renderArgs = arguments;
24 | })
25 | .destroy(function (selection, d){
26 | destroyArgs = arguments;
27 | });
28 |
29 |
30 | tape("A component should render a single instance.", function(test) {
31 | var div = d3.select(jsdom.jsdom().body).append("div");
32 | div.call(paragraph, "Hello Component");
33 | test.equal(div.html(), "Hello Component
");
34 | test.end();
35 | });
36 |
37 | tape("A component should accept a DOM node in place of a selection.", function(test) {
38 | var div = d3.select(jsdom.jsdom().body).append("div");
39 | paragraph(div.node(), "Hello Component");
40 | test.equal(div.html(), "Hello Component
");
41 | test.end();
42 | });
43 |
44 | tape("A component should render multiple instances.", function(test) {
45 | var div = d3.select(jsdom.jsdom().body).append("div");
46 |
47 | // Enter
48 | div.call(paragraph, [ "foo", "bar" ]);
49 | test.equal(div.html(), "foo
bar
");
50 |
51 | // Update + Enter
52 | div.call(paragraph, [ "fooz", "barz", "baz" ])
53 | test.equal(div.html(), "fooz
barz
baz
");
54 |
55 | // Update + Exit
56 | div.call(paragraph, [ "fooz", "baz" ])
57 | test.equal(div.html(), "fooz
baz
");
58 |
59 | // Exit
60 | div.call(paragraph, []);
61 | test.equal(div.html(), "");
62 |
63 | test.end();
64 | });
65 |
66 | tape("A component should be passed undefined as datum when data not specified.", function(test) {
67 | var div = d3.select(jsdom.jsdom().body).append("div");
68 | div.call(paragraph);
69 | test.equal(div.html(), '');
70 | test.equal(typeof paragraphDatum, "undefined");
71 | test.end();
72 | });
73 |
74 | tape("Livecycle arguments should be only (selection, d).", function(test) {
75 | var div = d3.select(jsdom.jsdom().body).append("div");
76 | div.call(argsTester, ["a", "b"]);
77 | div.call(argsTester, ["a"]);
78 | test.equal(createArgs.length, 3)
79 | test.equal(renderArgs.length, 3)
80 | test.equal(destroyArgs.length, 3);
81 | test.end();
82 | });
83 |
84 | tape("A component should return its merged Enter + Update selection.", function(test) {
85 | var div = d3.select(jsdom.jsdom().body).append("div");
86 | test.equal(paragraph(div, "Text").text(), "Text"); // Enter
87 | test.equal(paragraph(div, "Text").text(), "Text"); // Update
88 | test.end();
89 | });
90 |
--------------------------------------------------------------------------------
/src/component.js:
--------------------------------------------------------------------------------
1 | import { select } from 'd3-selection';
2 |
3 | // The name of the property used to store component instances on DOM nodes.
4 | const instanceProperty = '__instance__';
5 |
6 | // Sets the component instance property on the given DOM node.
7 | function setInstance(node, value) {
8 | node[instanceProperty] = value;
9 | }
10 |
11 | // Gets the component instance property from the given DOM node.
12 | const getInstance = node => node[instanceProperty];
13 |
14 | // Computes the data to pass into the data join from component invocation arguments.
15 | function dataArray(data, context) {
16 | data = Array.isArray(data) ? data : [data];
17 | return context ? data.map(d => Object.assign(Object.create(context), d)) : data;
18 | }
19 |
20 | // Destroys a descendant component instance.
21 | // Does not remove its DOM node, as one if its ancestors will be removed.
22 | function destroyDescendant() {
23 | const instance = getInstance(this);
24 | if (instance) {
25 | const {
26 | selection, datum, destroy, index,
27 | } = instance;
28 | destroy(selection, datum, index);
29 | }
30 | }
31 |
32 | // Destroys the component instance and its descendant component instances.
33 | function destroyInstance() {
34 | const {
35 | selection, datum, destroy, index,
36 | } = getInstance(this);
37 | selection.selectAll('*').each(destroyDescendant);
38 | const transition = destroy(selection, datum, index);
39 | (transition || selection).remove();
40 | }
41 |
42 | // No operation.
43 | const noop = () => null;
44 |
45 | // The component constructor, exposed as d3.component.
46 | export default function (tagName, className) {
47 | // Values set via setters.
48 | let create = noop;
49 | let render = noop;
50 | let destroy = noop;
51 | let key = null;
52 |
53 | // Checks if the given DOM node is managed by this component.
54 | function belongsToMe(node) {
55 | const instance = getInstance(node);
56 | return instance && instance.component === component;
57 | }
58 |
59 | // Returns DOM children managed by this component.
60 | function mine() {
61 | return Array.from(this.children).filter(belongsToMe);
62 | }
63 |
64 | // Creates a new component instance and stores it on the DOM node.
65 | function createInstance(datum, index) {
66 | const selection = select(this);
67 | setInstance(this, {
68 | component, selection, destroy, datum, index,
69 | });
70 | create(selection, datum, index);
71 | }
72 |
73 | // Renders the component instance, and stores its datum for later use (in destroy).
74 | function renderInstance(datum, index) {
75 | const instance = getInstance(this);
76 | instance.datum = datum;
77 | render(instance.selection, datum, index);
78 | }
79 |
80 | // The returned component instance.
81 | function component(container, data, context) {
82 | const selection = container.nodeName ? select(container) : container;
83 | const instances = selection
84 | .selectAll(mine)
85 | .data(dataArray(data, context), key);
86 | instances
87 | .exit()
88 | .each(destroyInstance);
89 | return instances
90 | .enter().append(tagName)
91 | .attr('class', className)
92 | .each(createInstance)
93 | .merge(instances)
94 | .each(renderInstance);
95 | }
96 |
97 | // Chainable setters.
98 | component.render = (_) => { render = _; return component; };
99 | component.create = (_) => { create = _; return component; };
100 | component.destroy = (_) => { destroy = _; return component; };
101 | component.key = (_) => { key = _; return component; };
102 |
103 | return component;
104 | }
105 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # d3-component
2 |
3 | A lightweight component abstraction for [D3.js](d3js.org).
4 |
5 | **Features:**
6 |
7 | * Encapsulates the [General Update Pattern](https://github.com/d3/d3-selection#selection_merge).
8 | * Composable (even [recursive](https://bl.ocks.org/curran/2aafab81bb2029e1f4f24d258b790ce4)!) stateless functional components.
9 | * Reliable `destroy` hook for cleaning things up.
10 | * Works great with [Redux](http://redux.js.org/).
11 |
12 | **Examples:**
13 |
105 |
106 | Using this component abstraction, you can easily encapsulate data-driven user interface components as conceptual "boxes-within-boxes", cleanly isolating concerns for various levels of your DOM tree. This component abstraction is similar in concept and functionality to [React Stateless Functional Components](https://hackernoon.com/react-stateless-functional-components-nine-wins-you-might-have-overlooked-997b0d933dbc#.dc21r5uj4). Everything a component needs to render itself and interact with application state gets passed down through the component tree at render time. Components don't store any local state; this is the main difference between d3-component and the [Towards Reusable Charts](https://bost.ocks.org/mike/chart/) pattern. No special treatment is given to events or event delegation, because the intended use is within a unidirectional data flow architecture like [Redux](http://redux.js.org/).
107 |
108 | ## Installing
109 |
110 | If you use NPM, `npm install d3-component`. Otherwise, download the [latest release](https://github.com/curran/d3-component/releases/latest). You can also load directly from [unpkg.com](https://unpkg.com) as a [standalone library](https://unpkg.com/d3-component@1). AMD, CommonJS, and vanilla environments are supported. In vanilla, a `d3` global is exported:
111 |
112 | ```html
113 |
114 |
115 |
118 | ```
119 |
120 | ## API Reference
121 |
122 | **Note:** There was a recent [major version release](https://github.com/curran/d3-component/releases/tag/v3.0.0), and along with it there were substantial [API Changes](https://github.com/curran/d3-component/issues/70).
123 |
124 | In summary, the API looks like this:
125 |
126 | ```js
127 | var myComponent = d3.component("div", "some-class")
128 | .create((selection, d, i) => { ... }) // Invoked for entering component instances.
129 | .render((selection, d, i) => { ... }) // Invoked for entering AND updating component instances.
130 | .destroy((selection, d, i) => { ... }); // Invoked for exiting instances, may return a transition.
131 |
132 | // To invoke the component,
133 | d3.select("body") // create a selection with a single element,
134 | .call(myComponent, "Hello d3-component!"); // then use selection.call().
135 | ```
136 |
137 | To see the full API in action, check out this ["Hello d3-component" example](https://bl.ocks.org/curran/c3d9783e641636479fa8e07a480e7233).
138 |
139 | # component(tagName[, className]))
140 |
141 | Creates a new component generator that manages and renders into DOM elements of the specified *tagName*.
142 |
143 | The optional parameter *className* determines the value of the `class` attribute on the DOM elements managed.
144 |
145 | # component.create(function(selection, d, i))
146 |
147 | Sets the create *function* of this component generator, which will be invoked whenever a new component instance is created, being passed a *selection* containing the current DOM element, the current datum (*d*), and the index of the current datum (*i*).
148 |
149 | # component.render(function(selection, d, i))
150 |
151 | Sets the render *function* of this component generator. This *function* will be invoked for each component instance during rendering, being passed a *selection* containing the current DOM element, the current datum (*d*), and the index of the current datum (*i*).
152 |
153 | # component.destroy(function(selection, d, i))
154 |
155 | Sets the destroy *function* of this component generator, which will be invoked whenever a component instance is destroyed, being passed a *selection* containing the current DOM element, the current datum (*d*), and the index of the current datum (*i*).
156 |
157 | When a component instance gets destroyed, the destroy *function* of all its children is also invoked (recursively), so you can be sure that this *function* will be invoked before the compoent instance is removed from the DOM.
158 |
159 | The destroy *function* may optionally return a transition, which will defer DOM element removal until after the transition is finished (but only if the parent component instance is not destroyed). Deeply nested component instances may have their DOM nodes removed before the transition completes, so it's best not to depend on the DOM node existing after the transition completes.
160 |
161 | # component.key(function)
162 |
163 | Sets the key *function* used in the internal [data join](https://github.com/d3/d3-selection#selection_data) when managing DOM elements for component instances. Specifying a key *function* is optional (the array index is used as the key by default), but will make re-rendering more efficient in cases where *data* arrays get reordered or spliced over time.
164 |
165 | # component(selection[,data[,context]])
166 |
167 | Renders the component to the given *selection*, a D3 selection containing a single DOM element. A raw DOM element may also be passed in as the *selection* argument. Returns a D3 selection containing the merged Enter and Update selections for component instances.
168 |
169 | * If *data* is specified and is an array, one component instance will be rendered for each element of the array, and the *[render function](component_render)* will receive a single element of the *data* array as its *d* argument.
170 | * **Useful case:** If *data* is specified as an empty array `[]`, all previously rendered component instances will be removed.
171 | * If *data* is specified and is not an array, exactly one component instance will be rendered, and the *[render function](component_render)* will receive the *data* value as its *d* argument.
172 | * If *data* is not specified, exactly one component instance will be rendered, and the *[render function](component_render)* will receive `undefined` as its *d* argument.
173 |
174 | In summary, components can be rendered using the following signatures:
175 |
176 | * `selection.call(myComponent, dataObject)` → One instance, render function *d* will be `dataObject`.
177 | * `selection.call(myComponent, dataArray)` → `dataArray.length` instances, render function *d* will be `dataArray[i]`
178 | * `selection.call(myComponent)` → One instance, render function *d* will be `undefined`.
179 |
180 | If a *context* object is specified, each data element in the data array will be shallow merged into a new object whose prototype is the *context* object, and the resulting array will be used in place of the *data* array. This is useful for passing down callback functions through your component tree. To clarify, the following two invocations are equivalent:
181 |
182 | ```js
183 | var context = {
184 | onClick: function (){ console.log("Clicked!");
185 | };
186 | selection.call(myComponent, dataArray.map(function (d){
187 | return Object.assign(Object.create(context), d);
188 | }));
189 | ```
190 |
191 | ```js
192 | var context = {
193 | onClick: function (){ console.log("Clicked!");
194 | };
195 | selection.call(myComponent, dataArray, context);
196 | ```
197 |
--------------------------------------------------------------------------------