├── .babelrc ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── __tests__ └── parse-css-selector.ts ├── package.json ├── src └── polyfill-css-has.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | dist/* 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jplhomer/polyfill-css-has/6f33ab277609147a8617fd946f8097fd1e599b38/.prettierrc -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CSS :has() Polyfill 2 | 3 | This polyfill allows you to query elements using the [CSS `:has()` pseudo-class](https://developer.mozilla.org/en-US/docs/Web/CSS/:has) which has not been implemented in any browsers yet. 4 | 5 | ## Installation 6 | 7 | ```sh 8 | yarn add polyfill-css-has 9 | ``` 10 | 11 | Then, in your JavaScript: 12 | 13 | ```js 14 | import querySelectorAllWithHas from 'polyfill-css-has'; 15 | 16 | // Get all paragraphs in the container which have links 17 | var items = querySelectorAllWithHas('.container > p:has(> a)'); 18 | 19 | // Optionally, pass an element to query against: 20 | var container = document.querySelector('.container'); 21 | var items = querySelectorAllWithHas('p:has(> a)', container); 22 | ``` 23 | 24 | ## How it works 25 | 26 | The Polyfill works by splitting up your selector into two chunks: 27 | 28 | 1. The scope which is to be queried for elements with `:has()` requirements 29 | 1. The specific selector inside the `:has()` class 30 | 31 | Each of the scope-level elements are then filtered by the `:has()` selector and returned in an array. 32 | 33 | ## Limitations 34 | 35 | * Does not support additional chained pseudo-classes like `:nth-child()` or `:empty()` 36 | * Does not support more than one `:has()` element in the selector 37 | 38 | ## Development 39 | 40 | To develop locally, ensure you have Node.js > v8.6.0 installed, and run: 41 | 42 | ```sh 43 | yarn 44 | ``` 45 | 46 | To build a development version of the polyfill, run: 47 | 48 | ```sh 49 | yarn dev 50 | ``` 51 | 52 | To build a production version of the polyfill, run: 53 | 54 | ```sh 55 | yarn build 56 | ``` 57 | 58 | ## Testing 59 | 60 | To run tests with Jest: 61 | 62 | ```sh 63 | yarn test 64 | 65 | # Watch for changes with Git: 66 | yarn test --watch 67 | ``` 68 | 69 | ## TODO 70 | 71 | * Compile Typescript/Webpack better for library consumption. http://marcobotto.com/compiling-and-bundling-typescript-libraries-with-webpack/ 72 | -------------------------------------------------------------------------------- /__tests__/parse-css-selector.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom'; 2 | 3 | import querySelectorAllWithHas, { 4 | getHasInnerSelector, 5 | getNodesInCurrentScope, 6 | filterNodesInScopeByHasSelector 7 | } from '../src/polyfill-css-has'; 8 | 9 | describe('Parse CSS selector', () => { 10 | it('returns the inner selector if there is one', () => { 11 | const results = getHasInnerSelector( 12 | '.c-entry-content > div:has(> .e-video)' 13 | ); 14 | 15 | expect(results).toBe('> .e-video'); 16 | }); 17 | 18 | it('returns false if no :has is present', () => { 19 | expect(getHasInnerSelector('.c-entry-content > p')).toBeFalsy(); 20 | }); 21 | }); 22 | 23 | describe('Get nodes in current scope', () => { 24 | const { 25 | window: { document } 26 | } = new JSDOM(` 27 | 28 |
29 |

Hello

30 |
31 |
32 |
33 |

Hey

34 | `); 35 | 36 | const results = getNodesInCurrentScope( 37 | document, 38 | '.c-entry-content > div:has(> .e-image)' 39 | ); 40 | 41 | expect(results.length).toBe(1); 42 | 43 | const div = results[0]; 44 | expect(div.tagName).toBe('DIV'); 45 | }); 46 | 47 | describe('Filter nodes in scope by has selector', () => { 48 | let document; 49 | 50 | beforeEach(() => { 51 | const { window } = new JSDOM(` 52 | 53 |
54 |

Hello

55 |
56 |
57 |
58 |
59 |
Hi there
60 |
61 |

Hey { 68 | const selector = '.c-entry-content > div:has(.e-image)'; 69 | const nodes = getNodesInCurrentScope(document, selector); 70 | const hasSelector = getHasInnerSelector(selector); 71 | const filteredNodes = filterNodesInScopeByHasSelector( 72 | nodes, 73 | hasSelector as string 74 | ); 75 | 76 | expect(filteredNodes.length).toBe(1); 77 | }); 78 | 79 | it('filters by direct selectors', () => { 80 | const selector = '.c-entry-content > div:has(> .e-image)'; 81 | const nodes = getNodesInCurrentScope(document, selector); 82 | const hasSelector = getHasInnerSelector(selector); 83 | const filteredNodes = filterNodesInScopeByHasSelector( 84 | nodes, 85 | hasSelector as string 86 | ); 87 | 88 | expect(filteredNodes.length).toBe(1); 89 | }); 90 | }); 91 | 92 | describe('querySelectorAllWithHas', () => { 93 | let document; 94 | 95 | beforeEach(() => { 96 | const { window } = new JSDOM(` 97 | 98 |

99 |

Hello

100 |
101 |
102 |
103 |
104 |
Hi there
105 |
106 |

Hey { 113 | const results = querySelectorAllWithHas( 114 | '.c-entry-content > div:has(> .e-image)', 115 | document 116 | ); 117 | 118 | expect(results.length).toBe(1); 119 | }); 120 | 121 | it('works like normal without has selector', () => { 122 | const results = querySelectorAllWithHas('.c-entry-content > p', document); 123 | 124 | expect(results.length).toBe(2); 125 | }); 126 | 127 | it('works like MDN docs prescribe', () => { 128 | const { window } = new JSDOM(` 129 | 130 |

131 |

Hello

132 | 133 | 134 | 135 |
136 |
137 |
138 |
139 |
Hi there
140 |
141 |

Hey img)', document); 147 | 148 | expect(results.length).toBe(1); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "polyfill-css-has", 3 | "version": "1.0.0", 4 | "main": "./dist/polyfill-css-has.js", 5 | "types": "./dist/polyfill-css-has.d.ts", 6 | "license": "MIT", 7 | "engines": { 8 | "node": "^8.6.0" 9 | }, 10 | "scripts": { 11 | "dev": "webpack --mode development", 12 | "build": "webpack --mode production", 13 | "test": "jest" 14 | }, 15 | "devDependencies": { 16 | "@types/jest": "^22.2.3", 17 | "babel-core": "^6.26.0", 18 | "babel-loader": "^7.1.4", 19 | "babel-preset-env": "^1.6.1", 20 | "jest": "^22.4.3", 21 | "jsdom": "^11.9.0", 22 | "prettier": "^1.12.1", 23 | "ts-jest": "^22.4.4", 24 | "ts-loader": "^4.2.0", 25 | "typescript": "^2.8.3", 26 | "webpack": "^4.6.0", 27 | "webpack-cli": "^2.0.15" 28 | }, 29 | "jest": { 30 | "transform": { 31 | "^.+\\.tsx?$": "ts-jest" 32 | }, 33 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 34 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"] 35 | }, 36 | "dependencies": { 37 | "core-js": "^2.5.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/polyfill-css-has.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/fn/array/from'; 2 | 3 | /** 4 | * Perform querySelectorAll using the experimental :has() selector, as 5 | * defined by MDN. 6 | * 7 | * @example 8 | * const results = querySelectorAll('.container > div:has(> img)'); 9 | * 10 | * @see https://developer.mozilla.org/en-US/docs/Web/CSS/:has 11 | * @param selector CSS selector containing :has() 12 | * @param dom Optional element to search. Defaults to document. 13 | */ 14 | export default function querySelectorAllWithHas( 15 | selector: string, 16 | dom?: Element 17 | ): Node[] { 18 | const node = dom || document; 19 | 20 | const hasSelector = getHasInnerSelector(selector); 21 | 22 | if (!hasSelector) { 23 | return Array.from(node.querySelectorAll(selector)); 24 | } 25 | 26 | const nodes = getNodesInCurrentScope(node, selector); 27 | 28 | return filterNodesInScopeByHasSelector(nodes, hasSelector as string); 29 | } 30 | 31 | /** 32 | * Get the inner-selector from the :has() statement. 33 | * Returns false if no :has() is present. 34 | * 35 | * @param selector A CSS selector possibly containing :has() 36 | */ 37 | export function getHasInnerSelector(selector: string): string | Boolean { 38 | const matches = /:has\((.*)\)/.exec(selector); 39 | 40 | if (!matches) { 41 | return false; 42 | } 43 | 44 | return matches[1]; 45 | } 46 | 47 | /** 48 | * Get the elements in the resolved scope prior to the :has() statement. 49 | * 50 | * @param dom Element 51 | * @param selector Selector 52 | */ 53 | export function getNodesInCurrentScope( 54 | dom: Element | Document, 55 | selector: string 56 | ): NodeList { 57 | const currentScopeSelector = getCurrentScopeSelector(selector); 58 | 59 | return dom.querySelectorAll(currentScopeSelector); 60 | } 61 | 62 | /** 63 | * Grab the top-level scope, immediately to the left of :has() 64 | * 65 | * @param selector 66 | */ 67 | function getCurrentScopeSelector(selector: string): string { 68 | return selector.slice(0, selector.indexOf(':has(')); 69 | } 70 | 71 | /** 72 | * Perform the querySelectorAll behavior against the nodes at the top level. 73 | * 74 | * @param nodes Filtered nodes from the prior scope. 75 | * @param selector The inner :has() selector 76 | */ 77 | export function filterNodesInScopeByHasSelector( 78 | nodes: NodeList, 79 | selector: string 80 | ): Node[] { 81 | let method: Function; 82 | 83 | method = selectorHasDirectDescendant(selector) 84 | ? filterNodeWithDirectDescendants 85 | : filterNode; 86 | 87 | return Array.from(nodes).filter(node => method(node, selector)); 88 | } 89 | 90 | function selectorHasDirectDescendant(selector: string): Boolean { 91 | return selector.trim().slice(0, 1) === '>'; 92 | } 93 | 94 | function scrubDirectDescendantFromSelector(selector: string): string { 95 | return selector 96 | .trim() 97 | .slice(1) 98 | .trim(); 99 | } 100 | 101 | function filterNode(node: Element, selector: string): Boolean { 102 | return !!node.querySelector(selector); 103 | } 104 | 105 | function filterNodeWithDirectDescendants( 106 | node: Element, 107 | selector: string 108 | ): Boolean { 109 | return Array.from((node).children).some(child => { 110 | return child.matches(scrubDirectDescendantFromSelector(selector)); 111 | }); 112 | } 113 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "target": "es6", 5 | "sourceMap": true, 6 | "declaration": true, 7 | "declarationDir": "./" 8 | }, 9 | "include": ["./src/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/polyfill-css-has.ts', 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.js?$/, 9 | use: 'babel-loader', 10 | exclude: /node_modules/ 11 | }, 12 | { 13 | test: /\.tsx?$/, 14 | use: 'ts-loader', 15 | exclude: /node_modules/ 16 | } 17 | ] 18 | }, 19 | resolve: { 20 | extensions: ['.tsx', '.ts', '.js'] 21 | }, 22 | output: { 23 | filename: 'polyfill-css-has.js', 24 | path: path.resolve(__dirname, 'dist'), 25 | libraryTarget: 'umd' 26 | } 27 | }; 28 | --------------------------------------------------------------------------------