├── .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 |
47 |
48 | 49 |
50 |
51 |
52 |
53 |
54 | 55 |
56 | 57 |
58 |
59 |
60 |
61 | 62 | 63 |
64 | 65 |
66 |
67 |
68 |
69 | 70 | 71 |
72 | 73 |
74 |
75 |
76 |
77 | 78 | 79 |
80 | 81 |
82 |
83 |
84 |
85 | 86 | 87 |
88 | 89 |
90 |
91 |
92 |
93 | 94 | 95 |
96 | 97 |
98 |
99 |
100 |
101 | 102 | 103 |
104 | 105 |
106 |
107 |
108 |
109 | 110 | 111 |
112 | 113 |
114 |
115 |
116 |
117 | 118 | 119 |
120 | 121 |
122 |
123 |
124 |
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 | --------------------------------------------------------------------------------