├── .codeclimate.yml
├── .coveralls.yml
├── .eslintignore
├── .gitignore
├── .npmignore
├── .nvmrc
├── .travis.yml
├── README.md
├── config
├── webpack.config.babel.js
├── webpack.config.development.babel.js
└── webpack.config.production.babel.js
├── example
└── index.html
├── package.json
├── src
├── components
│ ├── ContainerQuery.js
│ ├── DOMCache.js
│ ├── Query.js
│ ├── ResizeDetector.js
│ └── UIComponent.js
├── consumers
│ ├── container-queries.scss
│ └── container_queries.rb
├── index.js
└── range.js
└── test
├── .eslintrc.js
├── src
├── components
│ ├── ContainerQuery.test.js
│ ├── DOMCache.test.js
│ ├── Query.test.js
│ ├── ResizeDetector.test.js
│ └── UIComponent.test.js
├── index.test.js
└── range.test.js
└── test-helper.js
/.codeclimate.yml:
--------------------------------------------------------------------------------
1 | engines:
2 | eslint:
3 | enabled: true
4 | ratings:
5 | paths:
6 | - src/**
7 | - test/**
8 |
--------------------------------------------------------------------------------
/.coveralls.yml:
--------------------------------------------------------------------------------
1 | service_name: travis-ci
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 | lib
3 | coverage
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_STORE
2 | node_modules
3 | *.log
4 | .idea
5 | coverage
6 | *.sublime-*
7 | dist
8 | lib
9 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | *.log
2 | /.*/
3 | /coverage/
4 | /test/
5 | /.eslint*
6 | /.gitignore
7 | /.npmignore
8 | /.travis.yml
9 | /config
10 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 5.7.0
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 | before_install:
4 | - npm install -g npm@3.6.0
5 | script: npm run check
6 | after_success:
7 | - npm run test:cover
8 | - cat coverage/lcov.info | ./node_modules/.bin/coveralls
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ContainerQueries
2 |
3 | A set of utilities for creating simple, width-based container queries.
4 |
5 | [![Build status][travis-image]][travis-url] [![Coverage Status][coveralls-image]][coveralls-url] [![Maintained][maintained-image]][maintained-url] [![NPM version][npm-image]][npm-url] [![Dependency Status][dependency-image]][dependency-url] [![Dev Dependency Status][devDependency-image]][devDependency-url] [![Code Climate][climate-image]][climate-url]
6 |
7 | ## Installation
8 |
9 |
10 |
11 |
12 | ## Usage
13 |
14 | ### JavaScript
15 |
16 | First, import the `ContainerQuery` object from this package:
17 |
18 | ```js
19 | import ContainerQuery from 'container-queries';
20 | ```
21 |
22 | Then, create the container queries around a given node using the `create` method:
23 |
24 | ```js
25 | let myNode = document.getElementById('MyNode');
26 | let containerQuery = ContainerQuery.create(myNode);
27 | ```
28 |
29 | Finally, add your container query conditions using the `addQuery` method of the returned object. You can specify a `min` and/ or `max` width for which the query is considered active. By default, these measures are considered *inclusive*. If you wish to make one or both *exclusive*, pass the `inclusive` option with a value of `false` (all exclusive), `'min'` (max is exclusive), or `'max'` (min is exclusive).
30 |
31 | In addition, ensure that you pass an `identifier`; this is the value that must be used in your stylesheets to respond to the query. You can also provide a `test` method instead of a min/ max, which must take the current width and return a boolean indicating whether the query should match given that width.
32 |
33 | ```js
34 | containerQuery.addQuery({min: 320, identifier: 'phone-up'});
35 | containerQuery.addQuery({min: 1000, max: 2000, inclusive: 'min', identifier: 'big'});
36 | containerQuery.addQuery({
37 | test: function(width) { return (width % 2) === 0 },
38 | identifier: 'even',
39 | });
40 | ```
41 |
42 | These queries will automatically be updated as the parent of the node changes size.
43 |
44 | ### HTML
45 |
46 | As an alternative (or, in addition to) adding queries in JavaScript, you can embed them directly in your HTML. To do so, simply populate the `data-container-queries` attribute with a string representation of your queries. When doing a `min` query, use the `>` (or `>=`, for inclusivity) operator followed by the unit you wish to use. `max` queries can similarly be done using `<` and `<=` operators. A query with both a `min` and `max` uses both numbers, separated by ellipses, optionally with `>` and/ or `<` to specify exclusivity of the range (see example below).
47 |
48 | ```html
49 |
50 |
51 |
52 |
53 |
54 |
55 | ```
56 |
57 | Note that you will still have to run some JavaScript for the script to detect and install these queries. You can do so using the static `createAllWithin` method of the imported `ContainerQuery` object, passing it the root of your document:
58 |
59 | ```javascript
60 | import ContainerQuery from 'container-queries';
61 | ContainerQuery.createAllWithin(document);
62 | ```
63 |
64 | You must call this again whenever you are inserting new nodes into the DOM. You can cleanup after nodes are removed using the static `destroyAllWithin` method:
65 |
66 | ```javascript
67 | import ContainerQuery from 'container-queries';
68 |
69 | let nodeToRemove = document.getElementById('RemoveMe');
70 | nodeToRemove.parentNode.removeChild(nodeToRemove);
71 | ContainerQuery.destroyAllWithin(nodeToRemove);
72 | ```
73 |
74 | ### CSS
75 |
76 | The CSS for updating styles according to container queries is the same regardless of whether the query was added in JavaScript or HTML. This plugin uses the `data-container-query-matches` attribute to provide this information by populating it with a space-separated list of matching queries. You can therefore write any attribute selector using this data attribute to update your styles:
77 |
78 | ```css
79 | .my-component[data-container-query-matches="phone-up"] {} /* only phone query matches */
80 | .my-component[data-container-query-matches~="big"] {} /* big query (and possibly more) matches */
81 | ```
82 |
83 | This plugin includes styling utilities for a variety of pre- and post-processors to make these declarations more friendly.
84 |
85 |
86 | [travis-url]: https://travis-ci.org/lemonmade/container-queries
87 | [travis-image]: https://travis-ci.org/lemonmade/container-queries.svg?branch=master
88 |
89 | [coveralls-url]: https://coveralls.io/github/lemonmade/container-queries?branch=master
90 | [coveralls-image]: https://coveralls.io/repos/lemonmade/container-queries/badge.svg?branch=master&service=github
91 |
92 | [dependency-url]: https://david-dm.org/lemonmade/container-queries
93 | [dependency-image]: https://david-dm.org/lemonmade/container-queries.svg
94 |
95 | [devDependency-url]: https://david-dm.org/lemonmade/container-queries/dev-status.svg
96 | [devDependency-image]: https://david-dm.org/lemonmade/container-queries#info=devDependencies
97 |
98 | [npm-url]: https://npmjs.org/package/container-queries
99 | [npm-image]: http://img.shields.io/npm/v/container-queries.svg?style=flat-square
100 |
101 | [climate-url]: https://codeclimate.com/github/lemonmade/container-queries
102 | [climate-image]: http://img.shields.io/codeclimate/github/lemonmade/container-queries.svg?style=flat-square
103 |
104 | [maintained-url]: https://github.com/lemonmade/container-queries/pulse
105 | [maintained-image]: http://img.shields.io/badge/status-maintained-brightgreen.svg?style=flat-square
106 |
--------------------------------------------------------------------------------
/config/webpack.config.babel.js:
--------------------------------------------------------------------------------
1 | export default {
2 | module: {
3 | loaders: [
4 | {
5 | test: /\.js$/,
6 | loader: 'babel',
7 | exclude: /node_modules/,
8 | },
9 | ],
10 | },
11 |
12 | output: {
13 | library: 'ContainerQueries',
14 | libraryTarget: 'umd',
15 | },
16 |
17 | resolve: {
18 | extensions: ['', '.js'],
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/config/webpack.config.development.babel.js:
--------------------------------------------------------------------------------
1 | import webpack from 'webpack';
2 | import defaultConfig from './webpack.config.babel';
3 |
4 | const {optimize: {OccurenceOrderPlugin}, DefinePlugin} = webpack;
5 |
6 | export default {
7 | ...defaultConfig,
8 | plugins: [
9 | new OccurenceOrderPlugin(),
10 | new DefinePlugin({
11 | 'process.env.NODE_ENV': JSON.stringify('development'),
12 | }),
13 | ],
14 | };
15 |
--------------------------------------------------------------------------------
/config/webpack.config.production.babel.js:
--------------------------------------------------------------------------------
1 | import webpack from 'webpack';
2 | import defaultConfig from './webpack.config.babel';
3 |
4 | const {
5 | optimize: {OccurenceOrderPlugin, UglifyJsPlugin},
6 | DefinePlugin,
7 | } = webpack;
8 |
9 | export default {
10 | ...defaultConfig,
11 | plugins: [
12 | new OccurenceOrderPlugin(),
13 | new DefinePlugin({
14 | 'process.env.NODE_ENV': JSON.stringify('production'),
15 | }),
16 | new UglifyJsPlugin({
17 | compressor: {
18 | screw_ie8: true, // eslint-disable-line camelcase
19 | warnings: false,
20 | },
21 | }),
22 | ],
23 | };
24 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Document
7 |
8 |
44 |
45 |
46 |
54 |
55 |
56 |
57 |
61 |
62 |
63 |
64 |
65 |
69 |
70 |
71 |
72 |
73 |
77 |
78 |
79 |
80 |
81 |
85 |
86 |
87 |
88 |
89 |
93 |
94 |
95 |
96 |
97 |
101 |
102 |
103 |
104 |
105 |
109 |
110 |
111 |
112 |
113 |
117 |
118 |
119 |
120 |
121 |
125 |
126 |
127 |
133 |
134 |
135 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "container-queries",
3 | "version": "0.0.1",
4 | "description": "Simple container queries for Shopify's admin.",
5 | "main": "lib/index.js",
6 | "jsnext:main": "src/index.js",
7 | "scripts": {
8 | "clean": "rimraf lib dist coverage",
9 | "lint": "eslint . --max-warnings 0",
10 | "test": "NODE_PATH=./test:./src:$NODE_PATH mocha test/ --recursive --compilers js:babel-core/register --reporter spec",
11 | "test:cover": "NODE_PATH=./test:./src:$NODE_PATH babel-node $(npm bin)/isparta cover --reporter text --reporter html $(npm bin)/_mocha test/ -- --recursive --reporter spec",
12 | "test:watch": "npm test -- --watch --reporter min",
13 | "check": "npm run lint && npm run test",
14 | "build:lib": "babel src --out-dir lib",
15 | "build:umd": "webpack src/index.js dist/container-queries.js --config config/webpack.config.development.babel.js",
16 | "build:umd:min": "webpack src/index.js dist/container-queries.min.js --config config/webpack.config.production.babel.js",
17 | "build": "npm run clean && npm run build:lib && npm run build:umd && npm run build:umd:min",
18 | "preversion": "npm run clean && npm run check",
19 | "version": "npm run build",
20 | "postversion": "git push && git push --tags && npm run clean",
21 | "prepublish": "npm run clean && npm run build"
22 | },
23 | "author": "Chris Sauve ",
24 | "license": "MIT",
25 | "babel": {
26 | "presets": ["shopify"]
27 | },
28 | "eslintConfig": {
29 | "extends": "plugin:shopify/esnext",
30 | "env": {
31 | "es6": true,
32 | "browser": true
33 | }
34 | },
35 | "devDependencies": {
36 | "babel-cli": "^6.6.5",
37 | "babel-core": "^6.7.4",
38 | "babel-eslint": "^6.0.0",
39 | "babel-loader": "^6.2.4",
40 | "babel-preset-shopify": "^10.1.0",
41 | "chai": "^3.5.0",
42 | "coveralls": "^2.11.9",
43 | "eslint": "^2.5.3",
44 | "eslint-plugin-shopify": "^10.8.0",
45 | "isparta": "^4.0.0",
46 | "jsdom": "^8.2.0",
47 | "mocha": "^2.4.5",
48 | "rimraf": "^2.5.2",
49 | "sinon": "^1.17.3",
50 | "sinon-chai": "^2.8.0",
51 | "webpack": "^1.12.14"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/ContainerQuery.js:
--------------------------------------------------------------------------------
1 | import Query from './Query';
2 | import UIComponent from './UIComponent';
3 | import ResizeDetector from './ResizeDetector';
4 | import {minMaxInclusiveFromIdentifier} from '../range';
5 |
6 | export const containerQueryAttribute = 'data-container-queries';
7 | export const containerQueryMatchesAttribute = 'data-container-query-matches';
8 |
9 | export default class ContainerQuery extends UIComponent {
10 | static selector = `[${containerQueryAttribute}]`;
11 |
12 | constructor(node, queries = []) {
13 | super(node);
14 | this.queries = queries.concat(queriesFromNode(node)).map((query) => new Query(query));
15 |
16 | this.update = this.update.bind(this);
17 | this.resizeDetector = ResizeDetector.create(this.node && this.node.parentNode);
18 | this.resizeDetector.addListener(this.update);
19 | }
20 |
21 | update(width = this.resizeDetector.width) {
22 | const {queries, node} = this;
23 |
24 | const matches = queries.filter((query) => {
25 | query.update(width);
26 | return query.matches;
27 | }).map((query) => query.identifier);
28 |
29 | node.setAttribute(containerQueryMatchesAttribute, matches.join(' '));
30 | }
31 |
32 | addQuery(query) {
33 | const newQuery = new Query(query);
34 | this.queries.push(newQuery);
35 | this.update();
36 | return newQuery;
37 | }
38 |
39 | addQueries(allQueries) {
40 | const newQueries = allQueries.map((options) => new Query(options));
41 | this.queries = this.queries.concat(newQueries);
42 | this.update();
43 | return newQueries;
44 | }
45 |
46 | destroy() {
47 | this.queries = [];
48 |
49 | if (this.resizeDetector != null) {
50 | this.resizeDetector.removeListener(this.update);
51 | delete this.resizeDetector;
52 | }
53 |
54 | super.destroy();
55 | }
56 | }
57 |
58 | const queryExtractor = /([^:,\s]+):\s+([^,\s]+)/g;
59 |
60 | function queriesFromNode(node) {
61 | const attribute = node.getAttribute(containerQueryAttribute);
62 | if (!attribute) { return []; }
63 |
64 | const queries = [];
65 | let match = queryExtractor.exec(attribute);
66 |
67 | while (match) {
68 | queries.push({
69 | identifier: match[1].trim(),
70 | ...minMaxInclusiveFromIdentifier(match[2].trim()),
71 | });
72 |
73 | match = queryExtractor.exec(attribute);
74 | }
75 |
76 | return queries;
77 | }
78 |
--------------------------------------------------------------------------------
/src/components/DOMCache.js:
--------------------------------------------------------------------------------
1 | let cache = new WeakMap();
2 |
3 | const DOMCache = Object.freeze({
4 | clear() {
5 | cache = new WeakMap();
6 | },
7 |
8 | clearForNode(node) {
9 | cache.delete(node);
10 | },
11 |
12 | setValueForNode(node, {key, value}) {
13 | const currentCache = cache.get(node) || {};
14 | currentCache[key] = value;
15 | cache.set(node, currentCache);
16 | },
17 |
18 | deleteValueForNode(node, {key}) {
19 | delete (cache.get(node) || {})[key];
20 | },
21 |
22 | getValueForNode(node, {key}) {
23 | return (cache.get(node) || {})[key];
24 | },
25 | });
26 |
27 | export default DOMCache;
28 |
--------------------------------------------------------------------------------
/src/components/Query.js:
--------------------------------------------------------------------------------
1 | import {Inclusivity, identifierForMinMax, effectiveMinMax} from '../range';
2 |
3 | let queryIndex = 1;
4 |
5 | export default class Query {
6 | constructor({test, identifier, min, max, inclusive} = {}) {
7 | const inclusivity = new Inclusivity(inclusive);
8 | const {min: adjustedMin, max: adjustedMax} = effectiveMinMax(min, max, {withInclusivity: inclusivity});
9 |
10 | this.identifier = identifier || identifierForMinMax(min, max, {withInclusivity: inclusivity}) || `ContainerQuery${queryIndex++}`;
11 | this.matches = false;
12 | this._listeners = [];
13 |
14 | if (test != null) {
15 | this.test = test;
16 | } else {
17 | this.test = createConditionFromMinMax(adjustedMin, adjustedMax);
18 | }
19 | }
20 |
21 | onChange(listener) {
22 | this._listeners.push(listener);
23 | }
24 |
25 | update(width) {
26 | const lastMatches = this.matches;
27 | this.matches = this.test(width);
28 |
29 | if (this.matches !== lastMatches) {
30 | for (const listener of this._listeners) { listener(this); }
31 | }
32 |
33 | return this.matches;
34 | }
35 | }
36 |
37 | function createConditionFromMinMax(min = 0, max = 100000) {
38 | return (width) => width >= min && width <= max;
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/ResizeDetector.js:
--------------------------------------------------------------------------------
1 | import UIComponent from './UIComponent';
2 |
3 | export default class ResizeDetector extends UIComponent {
4 | constructor(node) {
5 | super(node);
6 | this._listeners = [];
7 | this.update = this.update.bind(this);
8 | }
9 |
10 | get width() {
11 | return (this.node == null) ? 0 : this.node.offsetWidth;
12 | }
13 |
14 | get hasLoaded() {
15 | return (this.object != null) && (this.object.contentDocument != null) && (this.object.contentDocument.readyState === 'complete');
16 | }
17 |
18 | update() {
19 | const {width, _listeners} = this;
20 | _listeners.forEach((listener) => listener(width));
21 | }
22 |
23 | addListener(callback) {
24 | this.object = this.object || createResizeObject(this);
25 | this._listeners.push(callback);
26 | if (this.hasLoaded) { callback(this.width); }
27 | }
28 |
29 | removeListener(callback, {preserve = false} = {}) {
30 | this._listeners.splice(this._listeners.indexOf(callback), 1);
31 | if (!preserve && this._listeners.length === 0) { this.destroy(); }
32 | }
33 |
34 | destroy() {
35 | if (this.object && this.object.contentDocument) {
36 | this.object.contentDocument.defaultView.removeEventListener('resize', this.update);
37 | }
38 |
39 | this._listeners = [];
40 | super.destroy();
41 | }
42 | }
43 |
44 | const objectStyle = `
45 | display: block;
46 | position: absolute;
47 | top: 0;
48 | left: 0;
49 | height: 100%;
50 | width: 100%;
51 | overflow: hidden;
52 | pointer-events: none;
53 | z-index: -1;
54 | `;
55 |
56 | function createResizeObject(detector) {
57 | const {node, update} = detector;
58 | if (node == null) { return null; }
59 |
60 | const obj = document.createElement('object');
61 | obj.style.cssText = objectStyle;
62 | obj.tabindex = -1;
63 |
64 | obj.onload = (event) => {
65 | const content = event.target.contentDocument.defaultView;
66 | content.addEventListener('resize', update);
67 | update();
68 |
69 | delete obj.onload;
70 | };
71 |
72 | obj.data = 'about:blank';
73 |
74 | positionNodeRelatively(node);
75 | node.appendChild(obj);
76 | return obj;
77 | }
78 |
79 | const relativePositionValues = ['relative', 'absolute', 'fixed'];
80 |
81 | function positionNodeRelatively(node) {
82 | if (relativePositionValues.indexOf(getComputedStyle(node, 'position')) < 0) {
83 | node.style.position = 'relative';
84 | }
85 | }
86 |
87 | function getComputedStyle(element, prop) {
88 | return window.getComputedStyle(element, null).getPropertyValue(prop);
89 | }
90 |
--------------------------------------------------------------------------------
/src/components/UIComponent.js:
--------------------------------------------------------------------------------
1 | import DOMCache from './DOMCache';
2 |
3 | export default class UIComponent {
4 | static get identifier() {
5 | return this.name;
6 | }
7 |
8 | static for(node) {
9 | if (node == null) { return null; }
10 | return DOMCache.getValueForNode(node, {key: this.identifier});
11 | }
12 |
13 | static create(nodes, ...args) {
14 | if (nodes instanceof window.HTMLElement) {
15 | return this.for(nodes) || new this(nodes, ...args);
16 | }
17 |
18 | if (typeof nodes === 'string') {
19 | nodes = document.querySelectorAll(nodes);
20 | }
21 |
22 | return toArray(nodes)
23 | .map((node) => this.for(node) || new this(node, ...args));
24 | }
25 |
26 | static allWithin(root) {
27 | return allNodesWithin(root, {matchingSelector: this.selector})
28 | .map((node) => this.for(node))
29 | .filter((component) => component != null);
30 | }
31 |
32 | static createAllWithin(root, ...args) {
33 | return this.create(allNodesWithin(root, {matchingSelector: this.selector}), ...args);
34 | }
35 |
36 | static destroyAllWithin(root) {
37 | this.allWithin(root).forEach((component) => component.destroy());
38 | }
39 |
40 | constructor(node) {
41 | this.node = node;
42 |
43 | if (node != null) {
44 | DOMCache.setValueForNode(node, {key: this.constructor.identifier, value: this});
45 | }
46 | }
47 |
48 | destroy() {
49 | if (this.node != null) {
50 | DOMCache.deleteValueForNode(this.node, {key: this.constructor.identifier});
51 | delete this.node;
52 | }
53 | }
54 | }
55 |
56 | function allNodesWithin(root, {matchingSelector: selector}) {
57 | if (selector == null) { return []; }
58 | return toArray(root.querySelectorAll(selector));
59 | }
60 |
61 | function toArray(arrayLike) {
62 | return Array.prototype.slice.apply(arrayLike);
63 | }
64 |
--------------------------------------------------------------------------------
/src/consumers/container-queries.scss:
--------------------------------------------------------------------------------
1 | $CONTAINER_QUERY_ATTRIBUTE: unquote('data-container-query-matches') !default;
2 |
3 | @function _container-query-strip-units($number) {
4 | @return $number / ($number * 0 + 1);
5 | }
6 |
7 | @function _container-query-adjusted-length($length, $inclusive) {
8 | @return _container-query-strip-units($length);
9 | }
10 |
11 | @mixin container-query($identifier: null, $min: null, $max: null, $inclusive: true) {
12 | @if $min != null and $max != null {
13 | $min: _container-query-adjusted-length($min);
14 | $max: _container-query-adjusted-length($max);
15 | &[#{$CONTAINER_QUERY_ATTRIBUTE}~="#{$min}-#{$max}"] { @content; }
16 | } @else if $min != null {
17 | $min: _container-query-adjusted-length($min);
18 | &[#{$CONTAINER_QUERY_ATTRIBUTE}~=">=#{$min}"] { @content; }
19 | } @else if $max != null {
20 | $max: _container-query-adjusted-length($max);
21 | &[#{$CONTAINER_QUERY_ATTRIBUTE}~="<=#{$max}"] { @content; }
22 | } @else if $identifier != null {
23 | &[#{$CONTAINER_QUERY_ATTRIBUTE}~="#{$identifier}"] { @content; }
24 | }
25 | }
26 |
27 | @mixin cq($identifier: null, $min: null, $max: null, $inclusive: true) {
28 | @include container-query($identifier, $min, $max, $inclusive) { @content; }
29 | }
30 |
--------------------------------------------------------------------------------
/src/consumers/container_queries.rb:
--------------------------------------------------------------------------------
1 | def container_queries(queries)
2 | data = {}
3 |
4 | queries.each do |name, value|
5 | data["data-container-query-#{name}"] = value
6 | end
7 |
8 | data
9 | end
10 |
11 | # eg: container_queries(small: "<500", medium: 700, iphone: "320-500")
12 | # maybe: container_queries(500, 700, 900)
13 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import ContainerQuery from './components/ContainerQuery';
2 | export default ContainerQuery;
3 |
--------------------------------------------------------------------------------
/src/range.js:
--------------------------------------------------------------------------------
1 | export class Inclusivity {
2 | constructor(inclusive = true) {
3 | this.min = (inclusive === true) || (inclusive === 'min');
4 | this.max = (inclusive === true) || (inclusive === 'max');
5 | }
6 |
7 | get both() { return this.min && this.max; }
8 | get neither() { return !this.min && !this.max; }
9 | get value() { return this.both ? true : ((this.min && 'min') || (this.max && 'max') || false); }
10 | }
11 |
12 | export function identifierForMinMax(min, max, {withInclusivity: inclusivity} = {}) {
13 | if (min != null && max != null) {
14 | return `${min}${interiorForInclusivity(inclusivity)}${max}`;
15 | } else if (min != null) {
16 | return `${inclusivity.min ? '>=' : '>'}${min}`;
17 | } else if (max != null) {
18 | return `${inclusivity.max ? '<=' : '<'}${max}`;
19 | } else {
20 | return null;
21 | }
22 | }
23 |
24 | const maxRegex = /([<\.]=?)(\d+)/;
25 | const maxInclusiveRegex = /(<=|\.)/;
26 | const minRegex = /(>?=?)(\d+)[^\.>]*(>?)/;
27 |
28 | export function minMaxInclusiveFromIdentifier(identifier) {
29 | const result = {};
30 | const inclusivity = new Inclusivity();
31 |
32 | identifier
33 | .replace(maxRegex, (match, condition, number) => {
34 | inclusivity.max = maxInclusiveRegex.test(condition);
35 | result.max = parseInt(number, 10);
36 | return '';
37 | })
38 | .replace(minRegex, (match, beforeCondition, number, afterCondition) => {
39 | inclusivity.min = (beforeCondition === '>=') || ((beforeCondition === '') && (afterCondition !== '>'));
40 | result.min = parseInt(number, 10);
41 | return '';
42 | });
43 |
44 | if (result.max == null) { inclusivity.max = inclusivity.min; }
45 | if (result.min == null) { inclusivity.min = inclusivity.max; }
46 |
47 | result.inclusive = inclusivity.value;
48 |
49 | return result;
50 | }
51 |
52 | function interiorForInclusivity(inclusivity) {
53 | return `${inclusivity.min ? '.' : '>'}${inclusivity.neither ? '..' : '.'}${inclusivity.max ? '.' : '<'}`;
54 | }
55 |
56 | export function effectiveMinMax(min, max, {withInclusivity: inclusivity}) {
57 | const result = {};
58 | if (min != null) { result.min = inclusivity.min ? min : min + 1; }
59 | if (max != null) { result.max = inclusivity.max ? max : max - 1; }
60 | return result;
61 | }
62 |
--------------------------------------------------------------------------------
/test/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | es6: true,
4 | mocha: true,
5 | browser: true
6 | },
7 |
8 | globals: {
9 | expect: false,
10 | sinon: false,
11 | print: true
12 | },
13 |
14 | rules: {
15 | 'no-unused-expressions': 0,
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/test/src/components/ContainerQuery.test.js:
--------------------------------------------------------------------------------
1 | import 'test-helper';
2 | import ContainerQuery from 'components/ContainerQuery';
3 | import ResizeDetector from 'components/ResizeDetector';
4 | import {Inclusivity} from 'range';
5 |
6 | describe('ContainerQuery', () => {
7 | const cutoff = 500;
8 | let node;
9 | let nodeTwo;
10 | let cq;
11 | let resizeDetectorStub;
12 |
13 | beforeEach(() => {
14 | node = document.createElement('div');
15 | node.className = 'TEST_NODE';
16 | nodeTwo = node.cloneNode(true);
17 | [node, nodeTwo].forEach((aNode) => document.body.appendChild(aNode));
18 |
19 | resizeDetectorStub = {
20 | addListener: sinon.spy(),
21 | removeListener: sinon.spy(),
22 | };
23 |
24 | sinon.stub(ResizeDetector, 'create').returns(resizeDetectorStub);
25 | cq = new ContainerQuery(node);
26 | });
27 |
28 | afterEach(() => {
29 | cq.destroy();
30 | ResizeDetector.create.restore();
31 | [node, nodeTwo].forEach((aNode) => document.body.removeChild(aNode));
32 | });
33 |
34 | describe('#constructor()', () => {
35 | it('adds itself as a listener on the resize detector', () => {
36 | expect(resizeDetectorStub.addListener).to.have.been.calledWith(cq.update);
37 | });
38 |
39 | it('allows passing an initial set of queries', () => {
40 | cq.destroy();
41 | cq = new ContainerQuery(node, [{min: cutoff}]);
42 |
43 | resizeDetectorStub.width = cutoff + 1;
44 | cq.update();
45 |
46 | expect(cq.queries.length).to.equal(1);
47 | expect(node.getAttribute('data-container-query-matches')).to.equal(cq.queries[0].identifier);
48 | });
49 |
50 | describe('data queries', () => {
51 | const minCutoff = cutoff;
52 | const maxCutoff = cutoff * 2;
53 | const name = 'myQuery_strange-name_33';
54 |
55 | function setNodeDataAttribute(value) {
56 | node.setAttribute('data-container-queries', /:/.test(value) ? value : `${name}: ${value}`);
57 | cq.destroy();
58 | cq = new ContainerQuery(node);
59 | }
60 |
61 | function setWidthAndUpdate(width) {
62 | resizeDetectorStub.width = width;
63 | cq.update();
64 | }
65 |
66 | function testWidthsAroundCutoffs({min = false, max = false, inclusive = true}) {
67 | const inclusivity = new Inclusivity(inclusive);
68 |
69 | if (min) {
70 | setWidthAndUpdate(minCutoff - 1);
71 | expect(node.getAttribute('data-container-query-matches')).to.equal('');
72 |
73 | setWidthAndUpdate(minCutoff);
74 | expect(node.getAttribute('data-container-query-matches')).to.equal(inclusivity.min ? name : '');
75 |
76 | setWidthAndUpdate(minCutoff + 1);
77 | expect(node.getAttribute('data-container-query-matches')).to.equal(name);
78 | }
79 |
80 | if (max) {
81 | setWidthAndUpdate(maxCutoff - 1);
82 | expect(node.getAttribute('data-container-query-matches')).to.equal(name);
83 |
84 | setWidthAndUpdate(maxCutoff);
85 | expect(node.getAttribute('data-container-query-matches')).to.equal(inclusivity.max ? name : '');
86 |
87 | setWidthAndUpdate(maxCutoff + 1);
88 | expect(node.getAttribute('data-container-query-matches')).to.equal('');
89 | }
90 | }
91 |
92 | it('attaches named data queries with a "X" as an inclusive minimum width', () => {
93 | setNodeDataAttribute(minCutoff);
94 | testWidthsAroundCutoffs({min: true, inclusive: true});
95 | });
96 |
97 | it('attaches named data queries with a "Xpx" as an inclusive minimum width', () => {
98 | setNodeDataAttribute(`${minCutoff}px`);
99 | testWidthsAroundCutoffs({min: true, inclusive: true});
100 | });
101 |
102 | it('attaches named data queries with a ">=X" as an inclusive minimum width', () => {
103 | setNodeDataAttribute(`>=${minCutoff}`);
104 | testWidthsAroundCutoffs({min: true, inclusive: true});
105 | });
106 |
107 | it('attaches named data queries with a ">=Xpx" as an inclusive minimum width', () => {
108 | setNodeDataAttribute(`>=${minCutoff}px`);
109 | testWidthsAroundCutoffs({min: true, inclusive: true});
110 | });
111 |
112 | it('attaches named data queries with a ">X" as an exclusive minimum width', () => {
113 | setNodeDataAttribute(`>${minCutoff}`);
114 | testWidthsAroundCutoffs({min: true, inclusive: false});
115 | });
116 |
117 | it('attaches named data queries with a ">Xpx" as an exclusive minimum width', () => {
118 | setNodeDataAttribute(`>${minCutoff}px`);
119 | testWidthsAroundCutoffs({min: true, inclusive: false});
120 | });
121 |
122 | it('attaches named data queries with a "<=X" as an inclusive maximum width', () => {
123 | setNodeDataAttribute(`<=${maxCutoff}`);
124 | testWidthsAroundCutoffs({max: true, inclusive: true});
125 | });
126 |
127 | it('attaches named data queries with a "<=Xpx" as an inclusive maximum width', () => {
128 | setNodeDataAttribute(`<=${maxCutoff}px`);
129 | testWidthsAroundCutoffs({max: true, inclusive: true});
130 | });
131 |
132 | it('attaches named data queries with a " {
133 | setNodeDataAttribute(`<${maxCutoff}`);
134 | testWidthsAroundCutoffs({max: true, inclusive: false});
135 | });
136 |
137 | it('attaches named data queries with a " {
138 | setNodeDataAttribute(`<${maxCutoff}px`);
139 | testWidthsAroundCutoffs({max: true, inclusive: false});
140 | });
141 |
142 | it('attaches named data queries with a "X...Y" as an inclusive minimum and maximum width', () => {
143 | setNodeDataAttribute(`${minCutoff}...${maxCutoff}`);
144 | testWidthsAroundCutoffs({min: true, max: true, inclusive: true});
145 | });
146 |
147 | it('attaches named data queries with a "Xpx...Ypx" as an inclusive minimum and maximum width', () => {
148 | setNodeDataAttribute(`${minCutoff}px...${maxCutoff}px`);
149 | testWidthsAroundCutoffs({min: true, max: true, inclusive: true});
150 | });
151 |
152 | it('attaches named data queries with a "X>..Y" as an exclusive minimum and inclusive maximum width', () => {
153 | setNodeDataAttribute(`${minCutoff}>..${maxCutoff}`);
154 | testWidthsAroundCutoffs({min: true, max: true, inclusive: 'max'});
155 | });
156 |
157 | it('attaches named data queries with a "Xpx>..Ypx" as an exclusive minimum and inclusive maximum width', () => {
158 | setNodeDataAttribute(`${minCutoff}px>..${maxCutoff}px`);
159 | testWidthsAroundCutoffs({min: true, max: true, inclusive: 'max'});
160 | });
161 |
162 | it('attaches named data queries with a "X... {
163 | setNodeDataAttribute(`${minCutoff}..<${maxCutoff}`);
164 | testWidthsAroundCutoffs({min: true, max: true, inclusive: 'min'});
165 | });
166 |
167 | it('attaches named data queries with a "Xpx.. {
168 | setNodeDataAttribute(`${minCutoff}px..<${maxCutoff}px`);
169 | testWidthsAroundCutoffs({min: true, max: true, inclusive: 'min'});
170 | });
171 |
172 | it('attaches named data queries with a "X>.. {
173 | setNodeDataAttribute(`${minCutoff}>..<${maxCutoff}`);
174 | testWidthsAroundCutoffs({min: true, max: true, inclusive: false});
175 | });
176 |
177 | it('attaches named data queries with a "Xpx>.. {
178 | setNodeDataAttribute(`${minCutoff}px>..<${maxCutoff}px`);
179 | testWidthsAroundCutoffs({min: true, max: true, inclusive: false});
180 | });
181 |
182 | it('attaches multiple, comma-separated named data queries', () => {
183 | const large = 'largeDown';
184 | const small = 'smallUp';
185 | setNodeDataAttribute(`${small}: >${minCutoff}, ${large}: <=${maxCutoff}`);
186 |
187 | setWidthAndUpdate(minCutoff - 1);
188 | expect(node.getAttribute('data-container-query-matches')).to.equal(large);
189 |
190 | setWidthAndUpdate(minCutoff);
191 | expect(node.getAttribute('data-container-query-matches')).to.equal(large);
192 |
193 | setWidthAndUpdate(minCutoff + 1);
194 | expect(node.getAttribute('data-container-query-matches')).to.equal(`${small} ${large}`);
195 |
196 | setWidthAndUpdate(maxCutoff - 1);
197 | expect(node.getAttribute('data-container-query-matches')).to.equal(`${small} ${large}`);
198 |
199 | setWidthAndUpdate(maxCutoff);
200 | expect(node.getAttribute('data-container-query-matches')).to.equal(`${small} ${large}`);
201 |
202 | setWidthAndUpdate(maxCutoff + 1);
203 | expect(node.getAttribute('data-container-query-matches')).to.equal(small);
204 | });
205 | });
206 | });
207 |
208 | describe('#update()', () => {
209 | let query;
210 |
211 | beforeEach(() => {
212 | query = cq.addQuery({min: cutoff});
213 | });
214 |
215 | it('uses the resize detector width if none is provided', () => {
216 | resizeDetectorStub.width = cutoff + 1;
217 | cq.update();
218 | expect(node.getAttribute('data-container-query-matches')).to.equal(query.identifier);
219 | });
220 |
221 | it('adds an attribute with matching queries', () => {
222 | cq.update(cutoff + 1);
223 | expect(node.getAttribute('data-container-query-matches')).to.equal(query.identifier);
224 | });
225 |
226 | it('does not add an attribute for non-matching queries', () => {
227 | cq.update(cutoff - 1);
228 | expect(node.getAttribute('data-container-query-matches')).to.be.empty;
229 | });
230 |
231 | it('removes the attribute for a formerly matching query', () => {
232 | cq.update(cutoff + 1);
233 | cq.update(cutoff - 1);
234 | expect(node.getAttribute('data-container-query-matches')).to.be.empty;
235 | });
236 |
237 | it('includes multiple matching queries as a space-separated list', () => {
238 | const queryTwo = cq.addQuery({min: cutoff - 1});
239 | cq.update(cutoff + 1);
240 | expect(node.getAttribute('data-container-query-matches')).to.equal(`${query.identifier} ${queryTwo.identifier}`);
241 | });
242 | });
243 |
244 | describe('#addQuery()', () => {
245 | beforeEach(() => {
246 | resizeDetectorStub.width = 555;
247 | });
248 |
249 | it('immediately evaluates the new query', () => {
250 | const query = cq.addQuery({test: sinon.stub().returns(true)});
251 | expect(query.test).to.have.been.calledWith(resizeDetectorStub.width);
252 | expect(node.getAttribute('data-container-query-matches')).to.equal(query.identifier);
253 | });
254 | });
255 |
256 | describe('#addQueries', () => {
257 | it('adds all queries and runs them once immediately', () => {
258 | const queries = cq.addQueries([
259 | {test: sinon.stub().returns(true)},
260 | {test: sinon.stub().returns(true)},
261 | ]);
262 |
263 | queries.forEach((query) => {
264 | expect(query.test).to.have.been.calledOnce;
265 | expect(query.test).to.have.been.calledWith(resizeDetectorStub.width);
266 | });
267 |
268 | expect(node.getAttribute('data-container-query-matches')).to.equal(
269 | queries.map((query) => query.identifier).join(' ')
270 | );
271 | });
272 | });
273 |
274 | describe('#destroy()', () => {
275 | it('clears out all references', () => {
276 | cq.destroy();
277 | expect(cq.node).to.be.undefined;
278 | expect(cq.queries).to.be.empty;
279 | expect(resizeDetectorStub.removeListener).to.have.been.calledWith(cq.update);
280 | });
281 | });
282 | });
283 |
--------------------------------------------------------------------------------
/test/src/components/DOMCache.test.js:
--------------------------------------------------------------------------------
1 | import 'test-helper';
2 | import DOMCache from 'components/DOMCache';
3 |
4 | describe('DOMCache', () => {
5 | const key = 'foo';
6 | const value = 200;
7 | let node;
8 |
9 | beforeEach(() => {
10 | node = document.createElement('div');
11 | });
12 |
13 | describe('#getValueForNode()', () => {
14 | it('returns nothing when no data has been set', () => {
15 | expect(DOMCache.getValueForNode(node, {key})).to.be.undefined;
16 | });
17 |
18 | it('returns a set value', () => {
19 | DOMCache.setValueForNode(node, {key, value});
20 | expect(DOMCache.getValueForNode(node, {key})).to.equal(value);
21 | });
22 | });
23 |
24 | describe('#setValueForNode()', () => {
25 | it('overwrites a set value', () => {
26 | DOMCache.setValueForNode(node, {key, value});
27 | expect(DOMCache.getValueForNode(node, {key})).to.equal(value);
28 |
29 | DOMCache.setValueForNode(node, {key, value: value * 2});
30 | expect(DOMCache.getValueForNode(node, {key})).to.equal(value * 2);
31 | });
32 | });
33 |
34 | describe('#deleteValueForNode', () => {
35 | it('removes a set value', () => {
36 | DOMCache.setValueForNode(node, {key, value});
37 | DOMCache.deleteValueForNode(node, {key});
38 | expect(DOMCache.getValueForNode(node, {key})).to.be.undefined;
39 | });
40 |
41 | it('does not choke on a value-less node', () => {
42 | expect(() => DOMCache.deleteValueForNode(node, {key})).not.to.throw(Error);
43 | });
44 | });
45 |
46 | describe('#clearForNode()', () => {
47 | it('does not throw when clearing a node without data', () => {
48 | expect(() => DOMCache.clearForNode(node)).not.to.throw(Error);
49 | });
50 |
51 | it('clears a set value', () => {
52 | DOMCache.setValueForNode(node, {key, value});
53 | DOMCache.clearForNode(node);
54 | expect(DOMCache.getValueForNode(node, {key})).to.be.undefined;
55 | });
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/test/src/components/Query.test.js:
--------------------------------------------------------------------------------
1 | import 'test-helper';
2 | import Query from 'components/Query';
3 |
4 | describe('Query', () => {
5 | describe('#identifier', () => {
6 | const min = 500;
7 | const max = 1000;
8 |
9 | it('uses a supplied identifier', () => {
10 | const identifier = 'foo-query';
11 | const query = new Query({identifier});
12 |
13 | expect(query.identifier).to.equal(identifier);
14 | });
15 |
16 | it('creates an identifier from a min value', () => {
17 | const query = new Query({min});
18 | expect(query.identifier).to.equal(`>=${min}`);
19 | });
20 |
21 | it('creates an identifier from an exclusive min value', () => {
22 | const query = new Query({min, inclusive: false});
23 | expect(query.identifier).to.equal(`>${min}`);
24 | });
25 |
26 | it('creates an identifier from a max value', () => {
27 | const query = new Query({max});
28 | expect(query.identifier).to.equal(`<=${max}`);
29 | });
30 |
31 | it('creates an identifier from an exclusive max value', () => {
32 | const query = new Query({max, inclusive: false});
33 | expect(query.identifier).to.equal(`<${max}`);
34 | });
35 |
36 | it('creates an identifier from a min and max value', () => {
37 | const query = new Query({min, max});
38 | expect(query.identifier).to.equal(`${min}...${max}`);
39 | });
40 |
41 | it('creates an identifier from an exclusive min and inclusive max value', () => {
42 | const query = new Query({min, max, inclusive: 'max'});
43 | expect(query.identifier).to.equal(`${min}>..${max}`);
44 | });
45 |
46 | it('creates an identifier from an inclusive min and exclusive max value', () => {
47 | const query = new Query({min, max, inclusive: 'min'});
48 | expect(query.identifier).to.equal(`${min}..<${max}`);
49 | });
50 |
51 | it('creates an identifier from an exclusive min and max value', () => {
52 | const query = new Query({min, max, inclusive: false});
53 | expect(query.identifier).to.equal(`${min}>..<${max}`);
54 | });
55 |
56 | it('uses a unique identifier for queries without min/ max/ identifier', () => {
57 | const queryOne = new Query();
58 | const queryTwo = new Query({test: sinon.spy()});
59 |
60 | expect(queryOne.identifier).to.be.a('string');
61 | expect(queryTwo.identifier).to.be.a('string');
62 | expect(queryOne.identifier).not.to.equal(queryTwo.identifier);
63 | });
64 | });
65 |
66 | describe('#test()', () => {
67 | const minCutoff = 500;
68 | const maxCutoff = 1000;
69 |
70 | it('uses the passed test parameter', () => {
71 | const test = sinon.spy(() => 'foo');
72 | const query = new Query({test});
73 | const result = query.test(minCutoff);
74 |
75 | expect(test).to.have.been.called;
76 | expect(result).to.equal('foo');
77 | });
78 |
79 | it('uses a passed minimum', () => {
80 | const query = new Query({min: minCutoff});
81 |
82 | expect(query.test(minCutoff)).to.be.true;
83 | expect(query.test(minCutoff + 1)).to.be.true;
84 | expect(query.test(minCutoff - 1)).to.be.false;
85 | expect(query.test(maxCutoff + 1)).to.be.true;
86 | });
87 |
88 | it('uses a passed exclusive minimum', () => {
89 | const query = new Query({min: minCutoff, inclusive: false});
90 |
91 | expect(query.test(minCutoff)).to.be.false;
92 | expect(query.test(minCutoff + 1)).to.be.true;
93 | expect(query.test(maxCutoff + 1)).to.be.true;
94 | });
95 |
96 | it('uses a passed maximum', () => {
97 | const query = new Query({max: maxCutoff});
98 |
99 | expect(query.test(maxCutoff)).to.be.true;
100 | expect(query.test(maxCutoff - 1)).to.be.true;
101 | expect(query.test(maxCutoff + 1)).to.be.false;
102 | expect(query.test(minCutoff - 1)).to.be.true;
103 | });
104 |
105 | it('uses a passed exclusive maximum', () => {
106 | const query = new Query({max: maxCutoff, inclusive: false});
107 |
108 | expect(query.test(maxCutoff)).to.be.false;
109 | expect(query.test(maxCutoff - 1)).to.be.true;
110 | expect(query.test(minCutoff - 1)).to.be.true;
111 | });
112 |
113 | it('uses a minimum and maximum', () => {
114 | const query = new Query({min: minCutoff, max: maxCutoff});
115 |
116 | expect(query.test(minCutoff)).to.be.true;
117 | expect(query.test(minCutoff + 1)).to.be.true;
118 | expect(query.test(minCutoff - 1)).to.be.false;
119 |
120 | expect(query.test(maxCutoff)).to.be.true;
121 | expect(query.test(maxCutoff - 1)).to.be.true;
122 | expect(query.test(maxCutoff + 1)).to.be.false;
123 | });
124 | });
125 |
126 | describe('#update()', () => {
127 | const cutoff = 500;
128 | let query;
129 | let listener;
130 |
131 | beforeEach(() => {
132 | query = new Query({min: cutoff});
133 | listener = sinon.spy();
134 | query.onChange(listener);
135 | });
136 |
137 | it('updates the #matches property and returns the new value', () => {
138 | expect(query.matches).to.be.false;
139 |
140 | let update = query.update(cutoff + 1);
141 | expect(update).to.be.true;
142 | expect(query.matches).to.be.true;
143 |
144 | update = query.update(cutoff - 1);
145 | expect(update).to.be.false;
146 | expect(query.matches).to.be.false;
147 | });
148 |
149 | it('calls a registered listener with the query', () => {
150 | query.update(cutoff + 1);
151 | expect(listener).to.have.been.calledWith(query);
152 | });
153 |
154 | it('only calls a listener when the matching status changes', () => {
155 | query.update(cutoff - 1);
156 | expect(listener).not.to.have.been.called;
157 |
158 | query.update(cutoff + 1);
159 | query.update(cutoff + 2);
160 | expect(listener).to.have.been.called.once;
161 | });
162 | });
163 | });
164 |
--------------------------------------------------------------------------------
/test/src/components/ResizeDetector.test.js:
--------------------------------------------------------------------------------
1 | import 'test-helper';
2 | import ResizeDetector from 'components/ResizeDetector';
3 |
4 | describe('ResizeDetector', () => {
5 | let objectStub;
6 | let node;
7 | let detector;
8 |
9 | beforeEach(() => {
10 | node = document.createElement('div');
11 | node.appendChild(document.createElement('div'));
12 |
13 | objectStub = {
14 | setAttribute(attr, value) { this[attr] = value; },
15 | style: {},
16 | contentDocument: {
17 | defaultView: {
18 | addEventListener: sinon.spy(),
19 | removeEventListener: sinon.spy(),
20 | },
21 | body: {clientWidth: 0},
22 | readyState: '',
23 | },
24 | };
25 |
26 | sinon.stub(document, 'createElement').returns(objectStub);
27 | sinon.stub(window.Node.prototype, 'appendChild');
28 |
29 | detector = new ResizeDetector(node);
30 | });
31 |
32 | afterEach(() => {
33 | detector.destroy();
34 | document.createElement.restore();
35 | window.Node.prototype.appendChild.restore();
36 | });
37 |
38 | describe('.for()', () => {
39 | it('uses an existing resize detector if created', () => {
40 | expect(detector === ResizeDetector.for(node)).to.be.true;
41 | });
42 | });
43 |
44 | describe('resize object', () => {
45 | it('does not append an object until a listener is added', () => {
46 | expect(node.appendChild).not.to.have.been.called;
47 | detector.addListener(sinon.spy());
48 | expect(node.appendChild).to.have.been.calledWith(objectStub);
49 | });
50 |
51 | it('does not append an object if the node does not exist', () => {
52 | detector = new ResizeDetector();
53 | expect(document.createElement).not.to.have.been.called;
54 | });
55 |
56 | it('only appends a single object', () => {
57 | detector.addListener(sinon.spy());
58 | detector.addListener(sinon.spy());
59 | expect(node.appendChild).to.have.been.calledOnce;
60 | });
61 |
62 | it('appends an out-of-document-flow object to the root node', () => {
63 | detector.addListener(sinon.spy());
64 |
65 | const expectedStyle = {
66 | display: 'block',
67 | position: 'absolute',
68 | top: 0,
69 | left: 0,
70 | height: '100%',
71 | width: '100%',
72 | overflow: 'hidden',
73 | 'pointer-events': 'none',
74 | 'z-index': -1,
75 | };
76 |
77 | expect(objectStub).to.have.property('tabindex', -1);
78 | expect(objectStub).to.have.property('data', 'about:blank');
79 |
80 | const style = objectStub.style.cssText;
81 | Object.keys(expectedStyle).forEach((property) => {
82 | expect(style).to.include(`${property}: ${expectedStyle[property]}`);
83 | });
84 | });
85 |
86 | it('updates the node to be relatively positioned', () => {
87 | node.style.position = 'static';
88 | detector.addListener(sinon.spy());
89 | expect(node.style.position).to.equal('relative');
90 | });
91 |
92 | ['relative', 'absolute', 'fixed'].forEach((positioning) => {
93 | it(`does not update '${positioning}' positioning of the node`, () => {
94 | node.style.position = positioning;
95 | detector.addListener(sinon.spy());
96 | expect(node.style.position).to.equal(positioning);
97 | });
98 | });
99 |
100 | it('calls #update() on load and attaches a listener for resizes', () => {
101 | sinon.stub(detector, 'update');
102 |
103 | detector.addListener(sinon.spy());
104 | expect(detector.update).not.to.have.been.called;
105 |
106 | objectStub.onload({target: objectStub});
107 | expect(detector.update).to.have.been.called;
108 |
109 | const addEventListenerArgs = objectStub.contentDocument.defaultView.addEventListener.firstCall.args;
110 | addEventListenerArgs[1]();
111 | expect(addEventListenerArgs[0]).to.equal('resize');
112 | expect(detector.update).to.have.been.calledTwice;
113 | });
114 |
115 | it('does not create a listener if there is no node', () => {
116 | detector = new ResizeDetector();
117 | expect(() => detector.addListener(sinon.spy())).not.to.throw(Error);
118 | });
119 | });
120 |
121 | describe('#addListener', () => {
122 | let listener;
123 |
124 | beforeEach(() => {
125 | listener = sinon.spy();
126 | node.offsetWidth = 555;
127 | });
128 |
129 | it('adds a listener that is called on update', () => {
130 | detector.addListener(listener);
131 | detector.update();
132 |
133 | expect(listener).to.have.been.calledWith(node.offsetWidth);
134 | });
135 |
136 | it('calls the listener immediately if the object has loaded', () => {
137 | objectStub.contentDocument.readyState = 'complete';
138 | detector.addListener(listener);
139 | expect(listener).to.have.been.calledWith(node.offsetWidth);
140 | });
141 |
142 | it('does not call the listener immediately if the object has not loaded', () => {
143 | objectStub.contentDocument = null;
144 | detector.addListener(listener);
145 | expect(listener).not.to.have.been.calledWith(node.offsetWidth);
146 | });
147 |
148 | it('does not call the listener immediately if the object is not ready', () => {
149 | objectStub.contentDocument.readyState = '';
150 | detector.addListener(listener);
151 | expect(listener).not.to.have.been.calledWith(node.offsetWidth);
152 | });
153 | });
154 |
155 | describe('#removeListener', () => {
156 | let listener;
157 |
158 | beforeEach(() => {
159 | listener = sinon.spy();
160 | });
161 |
162 | it('destroys itself when the last listener is removed', () => {
163 | sinon.stub(detector, 'destroy');
164 | detector.addListener(listener);
165 | detector.removeListener(listener);
166 |
167 | expect(detector.destroy).to.have.been.called;
168 | });
169 |
170 | it('does not destroy itself when the preserve option is passed', () => {
171 | sinon.stub(detector, 'destroy');
172 | detector.addListener(listener);
173 | detector.removeListener(listener, {preserve: true});
174 |
175 | expect(detector.destroy).not.to.have.been.called;
176 | });
177 |
178 | it('adds does not call a removed listener', () => {
179 | node.offsetWidth = 555;
180 |
181 | detector.addListener(listener, {preserve: true});
182 | detector.removeListener(listener);
183 | detector.update();
184 |
185 | expect(listener).not.to.have.been.called;
186 | });
187 | });
188 |
189 | describe('#width', () => {
190 | it('has a 0 width when there is no node', () => {
191 | detector = new ResizeDetector();
192 | expect(detector.width).to.equal(0);
193 | });
194 |
195 | it('uses the offsetWidth of the node if present', () => {
196 | const width = 555;
197 | node.offsetWidth = width;
198 | expect(detector.width).to.equal(width);
199 | });
200 | });
201 |
202 | describe('#destroy()', () => {
203 | let listener;
204 |
205 | beforeEach(() => {
206 | listener = sinon.spy();
207 | detector.addListener(listener); // force the addition of the object
208 | });
209 |
210 | it('clears out all references', () => {
211 | detector.destroy();
212 |
213 | expect(detector.node).to.be.undefined;
214 | expect(objectStub.contentDocument.defaultView.removeEventListener).to.have.been.calledWith('resize', detector.update);
215 | });
216 |
217 | it('does not choke when no object has been created', () => {
218 | expect(() => new ResizeDetector().destroy()).not.to.throw(Error);
219 | });
220 |
221 | it('removes all listeners', () => {
222 | detector.destroy();
223 | detector.update();
224 |
225 | expect(listener).not.to.have.been.called;
226 | });
227 | });
228 | });
229 |
--------------------------------------------------------------------------------
/test/src/components/UIComponent.test.js:
--------------------------------------------------------------------------------
1 | import 'test-helper';
2 | import UIComponent from 'components/UIComponent';
3 | import DOMCache from 'components/DOMCache';
4 |
5 | describe('UIComponent', () => {
6 | let nodeOne;
7 | let nodeTwo;
8 | let myComponent;
9 | let MyComponent;
10 |
11 | beforeEach(() => {
12 | nodeOne = document.createElement('div');
13 | nodeOne.className = 'my-component';
14 | nodeTwo = nodeOne.cloneNode(false);
15 | [nodeOne, nodeTwo].forEach((node) => document.body.appendChild(node));
16 |
17 | MyComponent = class MyComponent extends UIComponent { // eslint-disable-line no-shadow
18 | static selector = '.my-component'
19 | };
20 | });
21 |
22 | afterEach(() => {
23 | [nodeOne, nodeTwo].forEach((node) => document.body.removeChild(node));
24 | });
25 |
26 | describe('.identifier', () => {
27 | it('uses the name of the component', () => {
28 | expect(MyComponent.identifier).to.equal('MyComponent');
29 | });
30 | });
31 |
32 | describe('.for()', () => {
33 | it('returns nothing if the node is null', () => {
34 | expect(MyComponent.for(null)).to.be.null;
35 | });
36 |
37 | it('returns the cached object for the component identifier', () => {
38 | expect(MyComponent.for(nodeOne)).to.be.undefined;
39 |
40 | const cachedObject = {foo: 'bar'};
41 | DOMCache.setValueForNode(nodeOne, {key: MyComponent.identifier, value: cachedObject});
42 | expect(MyComponent.for(nodeOne)).to.equal(cachedObject);
43 | });
44 | });
45 |
46 | describe('.create()', () => {
47 | let constructorSpy;
48 |
49 | beforeEach(() => {
50 | constructorSpy = sinon.spy();
51 |
52 | MyComponent = class MyComponent extends UIComponent { // eslint-disable-line no-shadow
53 | static selector = '.my-component'
54 |
55 | constructor(...args) {
56 | super(...args);
57 | constructorSpy(...args);
58 | }
59 | };
60 |
61 | sinon.stub(MyComponent, 'for');
62 | });
63 |
64 | afterEach(() => {
65 | MyComponent.for.restore && MyComponent.for.restore();
66 | });
67 |
68 | context('when a node is passed', () => {
69 | it('returns a non-null result of .for()', () => {
70 | const myObject = {};
71 | MyComponent.for.returns(myObject);
72 |
73 | expect(MyComponent.create(nodeOne)).to.equal(myObject);
74 | expect(MyComponent.for).to.have.been.calledWith(nodeOne);
75 | });
76 |
77 | it('creates a new instance when for does not return anything', () => {
78 | expect(MyComponent.create(nodeOne)).to.be.an.instanceOf(MyComponent);
79 | });
80 |
81 | it('passes arguments to the constructor', () => {
82 | MyComponent.create(nodeOne, 'foo', 'bar');
83 | expect(constructorSpy).to.have.been.calledWith(nodeOne, 'foo', 'bar');
84 | });
85 | });
86 |
87 | context('when an array-like is passed', () => {
88 | it('creates returns an array of constructed objects', () => {
89 | const created = MyComponent.create(document.querySelectorAll(`.${nodeOne.className}`));
90 | MyComponent.for.restore();
91 |
92 | expect(created).to.have.length(2);
93 | expect(created[0]).to.equal(MyComponent.for(nodeOne));
94 | expect(created[1]).to.equal(MyComponent.for(nodeTwo));
95 | });
96 |
97 | it('uses cached versions if they exist', () => {
98 | MyComponent.for.returns({});
99 | MyComponent.create([nodeOne, nodeTwo]);
100 |
101 | expect(MyComponent.for).to.have.been.calledTwice;
102 | expect(constructorSpy).not.to.have.been.called;
103 | });
104 |
105 | it('passes arguments to the constructor', () => {
106 | MyComponent.create(document.querySelectorAll(`.${nodeOne.className}`), 'foo', 'bar');
107 |
108 | expect(constructorSpy).to.have.been.calledTwice;
109 | [nodeOne, nodeTwo].forEach((node) => {
110 | expect(constructorSpy).to.have.been.calledWith(node, 'foo', 'bar');
111 | });
112 | });
113 | });
114 |
115 | context('when a string is passed', () => {
116 | it('creates and returns an array of constructed objects from querying the selector', () => {
117 | expect(MyComponent.create('.not-matching')).to.be.empty;
118 |
119 | const created = MyComponent.create(`.${nodeOne.className}`);
120 | MyComponent.for.restore();
121 |
122 | expect(created).to.have.length(2);
123 | expect(created[0]).to.equal(MyComponent.for(nodeOne));
124 | expect(created[1]).to.equal(MyComponent.for(nodeTwo));
125 | });
126 | });
127 | });
128 |
129 | describe('.createAllWithin()', () => {
130 | beforeEach(() => {
131 | sinon.stub(MyComponent, 'create', (node) => node);
132 | });
133 |
134 | afterEach(() => {
135 | MyComponent.create.restore();
136 | });
137 |
138 | it('calls create for every node matching the selector', () => {
139 | const nodes = [nodeOne, nodeTwo];
140 | const created = MyComponent.createAllWithin(document.body);
141 |
142 | expect(MyComponent.create).to.have.been.calledWith(nodes);
143 | expect(created).to.deep.equal(nodes);
144 | });
145 |
146 | it('passes along any arguments to each create call', () => {
147 | MyComponent.createAllWithin(document.body, 'foo', 'bar');
148 | expect(MyComponent.create).to.have.been.calledWith([nodeOne, nodeTwo], 'foo', 'bar');
149 | });
150 |
151 | it('does not create for non-matching nodes', () => {
152 | nodeTwo.className = 'not-matching';
153 | MyComponent.createAllWithin(document.body);
154 |
155 | expect(MyComponent.create).to.have.been.calledOnce;
156 | expect(MyComponent.create).to.have.been.calledWith([nodeOne]);
157 | });
158 | });
159 |
160 | describe('.destroyAllWithin()', () => {
161 | it('calls destroy on all contained object instances', () => {
162 | const components = [nodeOne, nodeTwo].map((node) => new MyComponent(node));
163 | components.forEach((component) => sinon.spy(component, 'destroy'));
164 |
165 | MyComponent.destroyAllWithin(document.body);
166 |
167 | components.forEach((component) => {
168 | expect(component.destroy).to.have.been.called;
169 | });
170 | });
171 | });
172 |
173 | describe('.allWithin()', () => {
174 | context('when there is no selector', () => {
175 | it('returns an empty array', () => {
176 | delete MyComponent.selector;
177 | myComponent = new MyComponent(nodeOne);
178 | expect(MyComponent.allWithin(document.body)).to.be.empty;
179 | });
180 | });
181 |
182 | context('when there is a selector', () => {
183 | it('returns the cached objects for all nodes matching the selector', () => {
184 | nodeTwo.className = 'not-matching';
185 | [nodeOne, nodeTwo].forEach((node) => new MyComponent(node));
186 |
187 | expect(MyComponent.allWithin(document.body)).to.have.length(1);
188 | expect(MyComponent.allWithin(document.body)[0]).to.equal(MyComponent.for(nodeOne));
189 | });
190 |
191 | it('only returns objects that have actually been created', () => {
192 | myComponent = new MyComponent(nodeTwo);
193 |
194 | const allWithin = MyComponent.allWithin(document.body);
195 | expect(allWithin).to.have.length(1);
196 | expect(allWithin[0]).to.equal(MyComponent.for(nodeTwo));
197 | expect(allWithin[0]).to.equal(myComponent);
198 | });
199 |
200 | it('only returns objects that match the calling class', () => {
201 | class OtherComponent extends UIComponent {}
202 |
203 | myComponent = new MyComponent(nodeOne);
204 | const otherComponent = new OtherComponent(nodeTwo);
205 |
206 | const allWithin = MyComponent.allWithin(document.body);
207 | expect(allWithin).to.have.length(1);
208 | expect(allWithin[0]).to.equal(MyComponent.for(nodeOne));
209 | expect(allWithin[0]).not.to.equal(otherComponent);
210 | });
211 | });
212 | });
213 |
214 | describe('#constructor()', () => {
215 | it('caches the element to its DOM node at the identifier of the component', () => {
216 | myComponent = new MyComponent(nodeOne);
217 | expect(DOMCache.getValueForNode(nodeOne, {key: MyComponent.identifier})).to.equal(myComponent);
218 | });
219 |
220 | it('does not do anything when no node is passed', () => {
221 | expect(() => new MyComponent()).not.to.throw(Error);
222 | });
223 | });
224 |
225 | describe('#destroy()', () => {
226 | it('caches the element to its DOM node at the identifier of the component', () => {
227 | myComponent = new MyComponent(nodeOne);
228 | myComponent.destroy();
229 | expect(DOMCache.getValueForNode(nodeOne, {key: MyComponent.identifier})).to.be.undefined;
230 | });
231 |
232 | it('does not do anything when no node was passed', () => {
233 | expect(() => new MyComponent().destroy()).not.to.throw(Error);
234 | });
235 | });
236 | });
237 |
--------------------------------------------------------------------------------
/test/src/index.test.js:
--------------------------------------------------------------------------------
1 | import 'test-helper';
2 | import DefaultExport from '../../src/index';
3 | import ContainerQuery from 'components/ContainerQuery';
4 |
5 | describe('ContainerQuery', () => {
6 | it('exports the ContainerQuery class', () => {
7 | expect(DefaultExport).to.equal(ContainerQuery);
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/test/src/range.test.js:
--------------------------------------------------------------------------------
1 | import 'test-helper';
2 | import {Inclusivity, identifierForMinMax, effectiveMinMax, minMaxInclusiveFromIdentifier} from 'range';
3 |
4 | describe('range utilities', () => {
5 | describe('Inclusivity', () => {
6 | it('identifies full inclusivity', () => {
7 | const inclusivity = new Inclusivity(true);
8 |
9 | expect(inclusivity.min).to.be.true;
10 | expect(inclusivity.max).to.be.true;
11 | expect(inclusivity.both).to.be.true;
12 | expect(inclusivity.neither).to.be.false;
13 | expect(inclusivity.value).to.be.true;
14 | });
15 |
16 | it('identifies min inclusivity', () => {
17 | const inclusivity = new Inclusivity('min');
18 |
19 | expect(inclusivity.min).to.be.true;
20 | expect(inclusivity.max).to.be.false;
21 | expect(inclusivity.both).to.be.false;
22 | expect(inclusivity.neither).to.be.false;
23 | expect(inclusivity.value).to.equal('min');
24 | });
25 |
26 | it('identifies max inclusivity', () => {
27 | const inclusivity = new Inclusivity('max');
28 |
29 | expect(inclusivity.min).to.be.false;
30 | expect(inclusivity.max).to.be.true;
31 | expect(inclusivity.both).to.be.false;
32 | expect(inclusivity.neither).to.be.false;
33 | expect(inclusivity.value).to.equal('max');
34 | });
35 |
36 | it('identifies full exclusivity', () => {
37 | const inclusivity = new Inclusivity(false);
38 |
39 | expect(inclusivity.min).to.be.false;
40 | expect(inclusivity.max).to.be.false;
41 | expect(inclusivity.both).to.be.false;
42 | expect(inclusivity.neither).to.be.true;
43 | expect(inclusivity.value).to.be.false;
44 | });
45 | });
46 |
47 | describe('parsing and stringifying', () => {
48 | const min = 500;
49 | const max = 1000;
50 |
51 | function testAllValuesForMinMaxInclusivity(theMin, theMax, inclusivity, expectedIdentifier) {
52 | expect(identifierForMinMax(theMin, theMax, {withInclusivity: inclusivity})).to.equal(expectedIdentifier);
53 |
54 | const {min: parsedMin, max: parsedMax, inclusive} = minMaxInclusiveFromIdentifier(expectedIdentifier);
55 | const {min: effectiveMin, max: effectiveMax} = effectiveMinMax(theMin, theMax, {withInclusivity: inclusivity});
56 |
57 | if (theMin == null) {
58 | expect(parsedMin).to.be.undefined;
59 | expect(effectiveMin).to.be.undefined;
60 | } else {
61 | expect(parsedMin).to.equal(theMin);
62 | expect(effectiveMin).to.equal(inclusivity.min ? theMin : theMin + 1);
63 | }
64 |
65 | if (theMax == null) {
66 | expect(parsedMax).to.be.undefined;
67 | expect(effectiveMax).to.be.undefined;
68 | } else {
69 | expect(parsedMax).to.equal(theMax);
70 | expect(effectiveMax).to.equal(inclusivity.max ? theMax : theMax - 1);
71 | }
72 |
73 | expect(inclusive).to.equal(inclusivity.value);
74 | }
75 |
76 | it('handles a min and no max', () => {
77 | testAllValuesForMinMaxInclusivity(min, null, new Inclusivity(true), `>=${min}`);
78 | testAllValuesForMinMaxInclusivity(min, null, new Inclusivity(false), `>${min}`);
79 | });
80 |
81 | it('handles a max and no min', () => {
82 | testAllValuesForMinMaxInclusivity(null, max, new Inclusivity(true), `<=${max}`);
83 | testAllValuesForMinMaxInclusivity(null, max, new Inclusivity(false), `<${max}`);
84 | });
85 |
86 | it('handles a min and max', () => {
87 | testAllValuesForMinMaxInclusivity(min, max, new Inclusivity(true), `${min}...${max}`);
88 | testAllValuesForMinMaxInclusivity(min, max, new Inclusivity(false), `${min}>..<${max}`);
89 | testAllValuesForMinMaxInclusivity(min, max, new Inclusivity('min'), `${min}..<${max}`);
90 | testAllValuesForMinMaxInclusivity(min, max, new Inclusivity('max'), `${min}>..${max}`);
91 | });
92 |
93 | it('handles zeros', () => {
94 | testAllValuesForMinMaxInclusivity(0, 0, new Inclusivity(true), '0...0');
95 | });
96 |
97 | it('handles no min and no max', () => {
98 | expect(identifierForMinMax(null, null)).to.be.null;
99 | });
100 | });
101 | });
102 |
--------------------------------------------------------------------------------
/test/test-helper.js:
--------------------------------------------------------------------------------
1 | // Set up sinon and chai
2 |
3 | import sinon from 'sinon';
4 | import chai, {expect} from 'chai';
5 | import sinonChai from 'sinon-chai';
6 |
7 | chai.use(sinonChai);
8 |
9 | global.sinon = sinon;
10 | global.expect = expect;
11 |
12 | // Set up the DOM
13 |
14 | import {jsdom} from 'jsdom';
15 |
16 | global.document = jsdom('');
17 | global.window = document.defaultView;
18 | global.navigator = global.window.navigator;
19 |
20 | global.print = console.log.bind(console); // eslint-disable-line no-console
21 |
22 | // Global upkeep
23 |
24 | import DOMCache from 'components/DOMCache';
25 |
26 | beforeEach(() => {
27 | DOMCache.clear();
28 | });
29 |
--------------------------------------------------------------------------------