diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d36e1a8..441975c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,8 +10,7 @@ jobs: fail-fast: false matrix: node-version: - - 14 - - 12 + - 16 steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 diff --git a/index.d.ts b/index.d.ts index ded2995..3dcc3e2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable import/export */ import type {ParseSelector} from 'typed-query-selector/parser'; export interface Options { @@ -68,3 +67,34 @@ export default function elementReady( selector: string, options?: Options ): StoppablePromise; + +/** +Detect when elements are ready in the DOM. + +Useful for user-scripts that modify elements when they are added. + +@param selector - [CSS selector.](https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors) Prefix the element type to get a better return type. For example, `button.my-btn` instead of `.my-btn`. +@returns An async iterable which yields with each new matching element. + +@example +``` +import {observeReadyElements} from 'element-ready'; + +for await (const element of observeReadyElements('#unicorn')) { + console.log(element.id); + //=> 'unicorn' + + if (element.id === 'elephant') { + break; + } +} +``` +*/ +export function observeReadyElements>( + selector: Selector, + options?: Options +): AsyncIterable; +export function observeReadyElements( + selector: string, + options?: Options +): AsyncIterable; diff --git a/index.js b/index.js index c3cb441..b991022 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ import ManyKeysMap from 'many-keys-map'; import pDefer from 'p-defer'; +import createDeferredAsyncIterator from 'deferred-async-iterator'; const cache = new ManyKeysMap(); @@ -10,7 +11,7 @@ export default function elementReady(selector, { target = document, stopOnDomReady = true, waitForChildren = true, - timeout = Number.POSITIVE_INFINITY + timeout = Number.POSITIVE_INFINITY, } = {}) { const cacheKeys = [selector, stopOnDomReady, timeout, waitForChildren, target]; const cachedPromise = cache.get(cacheKeys); @@ -59,3 +60,68 @@ export default function elementReady(selector, { return Object.assign(promise, {stop: () => stop()}); } + +export function observeReadyElements(selector, { + target = document, + stopOnDomReady = true, + waitForChildren = true, + timeout = Number.POSITIVE_INFINITY, +} = {}) { + return { + [Symbol.asyncIterator]() { + const {next, complete, onCleanup, iterator} = createDeferredAsyncIterator(); + + function handleMutations(mutations) { + for (const {addedNodes} of mutations) { + for (const element of addedNodes) { + if (element.nodeType !== 1 || !element.matches(selector)) { + continue; + } + + // When it's ready, only stop if requested or found + if (isDomReady(target) && element) { + next(element); + continue; + } + + let current = element; + while (current) { + if (!waitForChildren || current.nextSibling) { + next(element); + continue; + } + + current = current.parentElement; + } + } + } + } + + const observer = new MutationObserver(handleMutations); + + observer.observe(target, { + childList: true, + subtree: true, + }); + + function stop() { + handleMutations(observer.takeRecords()); + complete(); + } + + onCleanup(() => { + observer.disconnect(); + }); + + if (stopOnDomReady) { + target.addEventListener('DOMContentLoaded', stop, {once: true}); + } + + if (timeout !== Number.POSITIVE_INFINITY) { + setTimeout(stop, timeout); + } + + return iterator; + }, + }; +} diff --git a/index.test-d.ts b/index.test-d.ts index e8bf7e3..0bd6cff 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ import {expectType} from 'tsd'; -import elementReady, {StoppablePromise} from './index.js'; +import elementReady, {StoppablePromise, observeReadyElements} from './index.js'; const promise = elementReady('#unicorn'); elementReady('#unicorn', {target: document}); elementReady('#unicorn', {target: document.documentElement}); -elementReady('#unicorn', {timeout: 1000000}); +elementReady('#unicorn', {timeout: 1_000_000}); elementReady('#unicorn', {stopOnDomReady: false}); @@ -20,3 +20,7 @@ expectType>(elementReady('input[t expectType>(elementReady(':root > button')); promise.stop(); + +const readyElements = observeReadyElements('#unicorn'); + +expectType>(readyElements); diff --git a/package.json b/package.json index 2f9d116..834030c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "domready" ], "dependencies": { + "deferred-async-iterator": "^1.0.1", "many-keys-map": "^1.0.3", "p-defer": "^4.0.0", "typed-query-selector": "^2.4.1" @@ -45,9 +46,9 @@ "ava": "^3.15.0", "delay": "^5.0.0", "jsdom": "^16.5.2", - "p-state": "^0.2.0", - "tsd": "^0.14.0", - "xo": "^0.38.2" + "p-state": "^0.2.1", + "tsd": "^0.16.0", + "xo": "^0.44.0" }, "xo": { "envs": [ diff --git a/readme.md b/readme.md index 1e32511..712f2ff 100644 --- a/readme.md +++ b/readme.md @@ -25,6 +25,23 @@ console.log(element.id); Returns a promise for a matching element. +### observeReadyElements(selector, options?) + +Returns an async iterable which yields with each new matching element. Useful for user-scripts that modify elements when they are added. + +```js +import {observeReadyElements} from 'element-ready'; + +for await (const element of observeReadyElements('#unicorn')) { + console.log(element.id); + //=> 'unicorn' + + if (element.id === 'elephant') { + break; + } +} +``` + #### selector Type: `string` diff --git a/test.js b/test.js index c27eee4..75f5815 100644 --- a/test.js +++ b/test.js @@ -2,13 +2,14 @@ import test from 'ava'; import delay from 'delay'; import {JSDOM} from 'jsdom'; import {promiseStateSync} from 'p-state'; -import elementReady from './index.js'; +import elementReady, {observeReadyElements} from './index.js'; const {window} = new JSDOM(); global.window = window; global.document = window.document; global.requestAnimationFrame = fn => setTimeout(fn, 16); global.cancelAnimationFrame = id => clearTimeout(id); +global.MutationObserver = window.MutationObserver; test('check if element ready', async t => { const elementCheck = elementReady('#unicorn', {stopOnDomReady: false}); @@ -28,7 +29,7 @@ test('check if element ready inside target', async t => { const target = document.createElement('p'); const elementCheck = elementReady('#unicorn', { target, - stopOnDomReady: false + stopOnDomReady: false, }); (async () => { @@ -46,12 +47,12 @@ test('check if different elements ready inside different targets with same selec const target1 = document.createElement('p'); const elementCheck1 = elementReady('.unicorn', { target: target1, - stopOnDomReady: false + stopOnDomReady: false, }); const target2 = document.createElement('span'); const elementCheck2 = elementReady('.unicorn', { target: target2, - stopOnDomReady: false + stopOnDomReady: false, }); (async () => { @@ -76,7 +77,7 @@ test('check if different elements ready inside different targets with same selec test('check if element ready after dom loaded', async t => { const elementCheck = elementReady('#bio', { - stopOnDomReady: true + stopOnDomReady: true, }); // The element will be added eventually, but we're not around to wait for it @@ -84,7 +85,7 @@ test('check if element ready after dom loaded', async t => { const element = document.createElement('p'); element.id = 'bio'; document.body.append(element); - }, 50000); + }, 50_000); const element = await elementCheck; t.is(element, undefined); @@ -96,7 +97,7 @@ test('check if element ready before dom loaded', async t => { document.body.append(element); const elementCheck = elementReady('#history', { - stopOnDomReady: true + stopOnDomReady: true, }); t.is(await elementCheck, element); @@ -105,18 +106,19 @@ test('check if element ready before dom loaded', async t => { test('check if element ready after timeout', async t => { const elementCheck = elementReady('#cheezburger', { stopOnDomReady: false, - timeout: 1000 + timeout: 1000, }); // The element will be added eventually, but we're not around to wait for it - setTimeout(() => { + const timeoutId = setTimeout(() => { const element = document.createElement('p'); element.id = 'cheezburger'; document.body.append(element); - }, 50000); + }, 50_000); const element = await elementCheck; t.is(element, undefined); + clearTimeout(timeoutId); }); test('check if element ready before timeout', async t => { @@ -126,7 +128,7 @@ test('check if element ready before timeout', async t => { const elementCheck = elementReady('#thunders', { stopOnDomReady: false, - timeout: 10 + timeout: 10, }); t.is(await elementCheck, element); @@ -193,18 +195,18 @@ test('ensure that the whole element has loaded', async t => { // Fake the pre-DOM-ready state Object.defineProperty(document, 'readyState', { - get: () => 'loading' + get: () => 'loading', }); const nav = document.querySelector('nav'); const partialCheck = elementReady('nav', { target: document, - waitForChildren: false + waitForChildren: false, }); const entireCheck = elementReady('nav', { target: document, - waitForChildren: true + waitForChildren: true, }); t.is(await partialCheck, nav, '