import React from 'dom-chef'; import {css} from 'code-tag'; import onetime from 'onetime'; import {ParseSelector} from 'typed-query-selector/parser.js'; import getCallerID from './caller-id.js'; import isDevelopmentVersion from './is-development-version.js'; type ObserverListener = (element: ExpectedElement, options: SignalAsOptions) => void; const animation = 'rgh-selector-observer'; const getListener = < Selector extends string, ExpectedElement extends ParseSelector, >( seenMark: string, selector: Selector, callback: ObserverListener, signal?: AbortSignal, ) => (event: AnimationEvent) => { const target = event.target as ExpectedElement; // The target can match a selector even if the animation actually happened on a ::before pseudo-element, so it needs an explicit exclusion here if (target.classList.contains(seenMark) || !target.matches(selector)) { return; } // Removes this specific selector’s animation once it was seen target.classList.add(seenMark); callback(target, {signal}); }; const registerAnimation = onetime((): void => { document.head.append(); }); export default function observe< Selector extends string, ExpectedElement extends ParseSelector, >( selectors: Selector | readonly Selector[], listener: ObserverListener, {signal}: SignalAsOptions = {}, ): void { if (signal?.aborted) { return; } const selector = String(selectors); // Array#toString() creates a comma-separated string const seenMark = 'rgh-seen-' + getCallerID(); registerAnimation(); const rule = document.createElement('style'); if (isDevelopmentVersion()) { // For debuggability rule.setAttribute('s', selector); } rule.textContent = css` :where(${String(selector)}):not(.${seenMark}) { animation: 1ms ${animation}; } `; document.body.prepend(rule); signal?.addEventListener('abort', () => { rule.remove(); }); window.addEventListener('animationstart', getListener(seenMark, selector, listener, signal), {signal}); }