@schalkneethling/css-media-pseudo-polyfill 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Schalk Neethling
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # css-media-pseudo-polyfill
2
+
3
+ A CSS polyfill for the [media pseudo-classes](https://html.spec.whatwg.org/multipage/semantics-other.html#selector-muted): `:playing`, `:paused`, `:seeking`, `:buffering`, `:stalled`, and `:muted`.
4
+
5
+ These pseudo-classes allow styling based on the playback, loading, and sound state of `<audio>` and `<video>` elements. Browser support is still incomplete — this polyfill detects which pseudo-classes the browser does not support and provides equivalent behavior via class selectors.
6
+
7
+ ## How it works
8
+
9
+ The polyfill runs in four stages:
10
+
11
+ 1. **Feature detection** — Each of the 7 media pseudo-classes is tested individually via `CSS.supports('selector(:name)')`. Only unsupported pseudo-classes are polyfilled. This allows partial support (e.g., Safari may support `:playing` but not `:buffering`).
12
+
13
+ 2. **CSS rewriting** — Inline `<style>` elements are parsed with [css-tree](https://github.com/nicolo-ribaudo/css-tree). For each rule containing a target pseudo-class, a class-based equivalent is injected as a sibling rule immediately after the original in the AST (e.g., `video:playing { ... }` is followed by `video.media-pseudo-polyfill-playing { ... }`). The browser skips the rule it doesn't understand and applies the class-based fallback — a natural progressive enhancement pair.
14
+
15
+ 3. **`<link>` stylesheet rewriting** — Same-origin `<link rel="stylesheet">` elements are fetched via the Fetch API and rewritten using the same text-based approach as inline `<style>` elements. The rewritten CSS is injected as a sibling `<style>` element and the original `<link>` is disabled. This avoids the CSSOM approach where browsers silently drop rules containing unrecognised pseudo-class selectors before the polyfill can process them. Cross-origin stylesheets are detected via origin comparison and skipped.
16
+
17
+ 4. **Element observation** — Media elements are discovered via `querySelectorAll` and a `MutationObserver`. Event listeners are attached to each element to track state changes. On every relevant event, the element's state is recomputed and the corresponding polyfill classes are toggled.
18
+
19
+ ## Entry points
20
+
21
+ | Export | Description |
22
+ | --------------- | ------------------------------------------------------- |
23
+ | `"."` (default) | Auto-applies the polyfill on `DOMContentLoaded` |
24
+ | `"./fn"` | Exports the `polyfill()` function for manual invocation |
25
+
26
+ The `"./fn"` entry point is useful when you need to run the polyfill earlier (e.g., from a synchronous `<script>` in `<head>`) to minimize the flash of unstyled content (FOUC).
27
+
28
+ ## Spec references
29
+
30
+ The pseudo-class definitions and their DOM conditions come from the [WHATWG HTML spec](https://html.spec.whatwg.org/multipage/semantics-other.html#selector-muted):
31
+
32
+ | Pseudo-class | DOM condition |
33
+ | ---------------- | -------------------------------------------------------------------------------- |
34
+ | `:playing` | `paused === false` |
35
+ | `:paused` | `paused === true` |
36
+ | `:seeking` | `seeking === true` |
37
+ | `:buffering` | `!paused && networkState === NETWORK_LOADING && readyState <= HAVE_CURRENT_DATA` |
38
+ | `:stalled` | matches `:buffering` AND the internal "is currently stalled" flag is `true` |
39
+ | `:muted` | `muted === true` |
40
+ | `:volume-locked` | Not polyfillable (no DOM surface) |
41
+
42
+ The `NETWORK_LOADING`, `HAVE_CURRENT_DATA`, and other constants are defined on the [HTMLMediaElement interface](https://html.spec.whatwg.org/multipage/media.html#htmlmediaelement) as `const unsigned short` values. They are available as both static and instance properties.
43
+
44
+ ## Design decisions
45
+
46
+ ### Per-pseudo-class detection
47
+
48
+ Rather than a single feature check, each pseudo-class is tested individually. Safari has partial support — it may implement some pseudo-classes but not others. Per-pseudo-class detection allows the polyfill to skip already-supported pseudo-classes and only rewrite and manage the unsupported ones. If `CSS.supports` is unavailable or throws, the pseudo-class is treated as unsupported.
49
+
50
+ ### Immediate-sibling injection for cascade preservation
51
+
52
+ For each rule containing a target pseudo-class, the polyfill inserts a class-based equivalent as a sibling rule immediately after the original in the AST. The original stylesheet is left untouched. This produces pairs like:
53
+
54
+ ```css
55
+ video:playing {
56
+ outline: 0.25rem solid green;
57
+ }
58
+ video.media-pseudo-polyfill-playing {
59
+ outline: 0.25rem solid green;
60
+ }
61
+ ```
62
+
63
+ Two alternative approaches were considered and rejected:
64
+
65
+ - **Clone-and-disable** (clone the entire AST, disable the original stylesheet): unnecessarily complex. Because the polyfill class is only present when the state is active, the injected rule is inert when the state doesn't apply — it cannot interfere with other rules regardless of source order. This is fundamentally different from attribute-based polyfills (e.g., container queries) where rewritten selectors match unconditionally.
66
+
67
+ - **Extract-and-append** (extract only rewritten rules, append to end): incorrect. Moving rules out of their `@layer`, `@media`, or `@supports` context would break the author's cascade intent.
68
+
69
+ Immediate-sibling injection keeps each rewritten rule inside the same block as its original, preserving `@layer`, `@media`, and `@supports` nesting with no special logic.
70
+
71
+ ### Specificity-neutral substitution
72
+
73
+ The polyfill replaces `PseudoClassSelector` nodes (specificity 0,1,0) with `ClassSelector` nodes (also 0,1,0). This substitution is specificity-neutral in all contexts — including inside `:is()` (which uses the most specific argument's specificity), `:where()` (which zeroes everything), and `:has()` (which contributes the argument's specificity). No `:where()` wrapping is needed.
74
+
75
+ ### `:volume-locked` handling
76
+
77
+ `:volume-locked` cannot be polyfilled because the "volume locked" flag is a user-agent-level boolean with no DOM surface. The polyfill handles it as follows to prevent broken stylesheets:
78
+
79
+ - **Lone selector** (`video:volume-locked { ... }`): the entire rule is removed
80
+ - **In a selector list** (`video:playing, video:volume-locked { ... }`): the `:volume-locked` branch is pruned; the `:playing` branch is rewritten normally
81
+ - **Inside `:is()` / `:where()`**: the `:volume-locked` argument is removed from the list
82
+ - **Inside `:not()`**: rewritten to `.media-pseudo-polyfill-volume-locked` (matches everything, since the class is never set — consistent behavior)
83
+
84
+ ### Pure state computation with externally managed stalled flag
85
+
86
+ The `computeStates()` function is pure — it reads properties from an `HTMLMediaElement` and returns a `Set<string>` of active states. The "is currently stalled" flag is passed in as a parameter rather than being computed internally because this flag is not directly observable from DOM properties. It must be tracked via the formal state machine defined in the [HTML spec](https://html.spec.whatwg.org/multipage/media.html#concept-media-load-resource):
87
+
88
+ - Set to `true` when the `stalled` event fires (browser's ~3 second stall timeout expired)
89
+ - Reset to `false` when `progress`, `emptied`, or `loadstart` fires
90
+
91
+ This separation keeps `computeStates()` easily testable with plain objects.
92
+
93
+ ### WeakMap for per-element state
94
+
95
+ Per-element state (event handler reference and stalled flag) is stored in a `WeakMap<HTMLMediaElement, ElementState>`. The `MutationObserver` callback explicitly cleans up when elements are removed from the DOM. The `WeakMap` acts as a safety net — if cleanup is missed (e.g., observer disconnected, edge case in DOM reparenting), the garbage collector can still reclaim the element and its associated state. A regular `Map` would hold a strong reference to the element key, preventing collection. On pages with many dynamic video elements (e.g., YouTube feed with preview hovers), this prevents memory leaks.
96
+
97
+ ### Single event handler per element
98
+
99
+ Media events do not bubble, so each element requires direct `addEventListener` calls. Rather than creating 14 separate handler functions per element, a single handler is created and registered for all 14 event types. The handler uses `event.type` in a switch statement to handle special cases (stalled flag transitions) before recomputing state. This means 14 registrations per element (unavoidable) but only 1 function object allocated per element — on a page with 20 videos, that is 20 function objects instead of 280.
100
+
101
+ ### Bind-once guard
102
+
103
+ Before attaching listeners, the polyfill checks whether the element is already tracked in the `WeakMap`. This prevents duplicate bindings from rapid DOM reparenting, where the `MutationObserver` may report the same element in both `removedNodes` and `addedNodes` in a single batch.
104
+
105
+ ### Fetch-based rewriting for `<link>` stylesheets
106
+
107
+ Same-origin `<link rel="stylesheet">` elements are rewritten by fetching the CSS text and running it through the same `rewriteCss()` function used for inline `<style>` elements. The rewritten output is injected as a `<style>` element immediately after the original `<link>`, which is then disabled.
108
+
109
+ An earlier approach used the CSSOM API (`sheet.cssRules` + `insertRule()`), which avoids an extra network request. However, this has a fundamental flaw: browsers silently drop rules containing unrecognised pseudo-class selectors during CSS parsing. By the time the polyfill walks the CSSOM, the rules it needs to rewrite have already been discarded. The fetch-based approach accesses the raw CSS text before browser parsing, ensuring all rules are available for rewriting.
110
+
111
+ Cross-origin stylesheets are detected via `new URL(href).origin` comparison against `window.location.origin` and skipped. This covers both relative paths (resolved to absolute by the browser) and explicit absolute URLs to external CDNs.
112
+
113
+ ## Known limitations
114
+
115
+ - **`:volume-locked` is not polyfillable.** The "volume locked" flag has no DOM surface. The polyfill removes `:volume-locked` selectors from rewritten stylesheets and never sets the corresponding class.
116
+
117
+ - **Class selectors can match non-media elements.** Native `:playing` only matches `<audio>` and `<video>` elements. The polyfilled `.media-pseudo-polyfill-playing` class has no such restriction. In practice this is not an issue because the polyfill only ever toggles these classes on media elements. An author would need to manually add the class to a non-media element to trigger a false positive.
118
+
119
+ - **FOUC window.** Stylesheet rewriting runs when the polyfill is invoked (at `DOMContentLoaded` for the default entry point). There is a window between first paint and polyfill initialization where pseudo-class-based styles are not applied. Use the `"./fn"` entry point from a synchronous `<script>` in `<head>` to minimize this gap.
120
+
121
+ - **Cross-origin `<link>` stylesheets are not rewritten.** The polyfill only fetches and rewrites same-origin stylesheets. Cross-origin sheets served from external CDNs are skipped.
package/dist/fn.d.mts ADDED
@@ -0,0 +1,4 @@
1
+ //#region src/polyfill.d.ts
2
+ declare function polyfill(): void;
3
+ //#endregion
4
+ export { polyfill };
package/dist/fn.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import { t as polyfill } from "./polyfill-BA69lY_2.mjs";
2
+ export { polyfill };
@@ -0,0 +1 @@
1
+ export { };
package/dist/index.mjs ADDED
@@ -0,0 +1,6 @@
1
+ import { t as polyfill } from "./polyfill-BA69lY_2.mjs";
2
+ //#region src/index.ts
3
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => polyfill());
4
+ else polyfill();
5
+ //#endregion
6
+ export {};
@@ -0,0 +1,503 @@
1
+ import { clone, generate, parse, walk } from "css-tree";
2
+ //#region src/constants.ts
3
+ const PSEUDO_CLASSES = [
4
+ "playing",
5
+ "paused",
6
+ "seeking",
7
+ "buffering",
8
+ "stalled",
9
+ "muted",
10
+ "volume-locked"
11
+ ];
12
+ const CLASS_PREFIX = "media-pseudo-polyfill-";
13
+ const MEDIA_EVENTS = [
14
+ "play",
15
+ "playing",
16
+ "pause",
17
+ "ended",
18
+ "seeking",
19
+ "seeked",
20
+ "waiting",
21
+ "canplay",
22
+ "canplaythrough",
23
+ "stalled",
24
+ "progress",
25
+ "volumechange",
26
+ "emptied",
27
+ "loadstart"
28
+ ];
29
+ //#endregion
30
+ //#region src/rewrite.ts
31
+ /**
32
+ * Rewrite CSS text using immediate-sibling injection. For each rule containing
33
+ * an unsupported media pseudo-class selector, a class-based equivalent is
34
+ * inserted as a sibling rule immediately after the original. The original rules
35
+ * are preserved — the browser silently skips rules it does not understand and
36
+ * applies the class-based fallback. Returns null if no rewrites occurred.
37
+ */
38
+ function rewriteCss(cssText, unsupported) {
39
+ const ast = parse(cssText);
40
+ let rewrote = false;
41
+ injectSiblingRules(ast, unsupported, (didRewrite) => {
42
+ if (didRewrite) rewrote = true;
43
+ });
44
+ if (!rewrote) return null;
45
+ const output = generate(ast);
46
+ if (!output.trim()) return null;
47
+ return output;
48
+ }
49
+ /**
50
+ * Walk the AST looking for Rule nodes that contain target pseudo-classes.
51
+ * For each match, clone the rule, rewrite selectors in the clone, and
52
+ * insert the clone immediately after the original in the parent list.
53
+ */
54
+ function injectSiblingRules(ast, unsupported, onRewrite) {
55
+ const rulesToProcess = [];
56
+ walk(ast, {
57
+ visit: "Rule",
58
+ enter(node, item, list) {
59
+ if (containsTargetPseudoClass(node, unsupported)) rulesToProcess.push({
60
+ rule: node,
61
+ item,
62
+ list
63
+ });
64
+ }
65
+ });
66
+ for (const { rule, item, list } of rulesToProcess) {
67
+ const cloned = clone(rule);
68
+ const prelude = cloned.prelude;
69
+ if (prelude.type !== "SelectorList") continue;
70
+ if (!rewriteSelectorList(prelude, unsupported)) continue;
71
+ if (prelude.children.isEmpty) continue;
72
+ const newItem = list.createItem(cloned);
73
+ if (item.next) list.insert(newItem, item.next);
74
+ else list.appendData(cloned);
75
+ onRewrite(true);
76
+ }
77
+ }
78
+ /**
79
+ * Check whether a CSS node contains any target pseudo-class selectors.
80
+ */
81
+ function containsTargetPseudoClass(node, unsupported) {
82
+ let found = false;
83
+ walk(node, {
84
+ visit: "PseudoClassSelector",
85
+ enter(pseudoNode) {
86
+ if (unsupported.has(pseudoNode.name)) {
87
+ found = true;
88
+ return this.break;
89
+ }
90
+ }
91
+ });
92
+ return found;
93
+ }
94
+ /**
95
+ * Rewrite pseudo-class selectors to class selectors in a SelectorList node,
96
+ * mutating it in place. Handles :volume-locked pruning and :not() rewriting.
97
+ * Returns whether any rewrites occurred.
98
+ *
99
+ * Used by both the <style> text rewriting path and the <link> CSSOM path.
100
+ */
101
+ function rewriteSelectorList(selectorList, unsupported) {
102
+ let rewrote = false;
103
+ walk(selectorList, {
104
+ visit: "PseudoClassSelector",
105
+ enter(node, pseudoItem, pseudoList) {
106
+ if (!unsupported.has(node.name)) return;
107
+ if (node.name === "volume-locked") return;
108
+ rewrote = true;
109
+ const replacement = pseudoList.createItem({
110
+ type: "ClassSelector",
111
+ name: `${CLASS_PREFIX}${node.name}`,
112
+ loc: node.loc
113
+ });
114
+ pseudoList.replace(pseudoItem, replacement);
115
+ }
116
+ });
117
+ if (unsupported.has("volume-locked")) {
118
+ if (handleVolumeLocked(selectorList)) rewrote = true;
119
+ }
120
+ return rewrote;
121
+ }
122
+ /**
123
+ * Handle :volume-locked pseudo-class in a selector list. Although unpolyfillable,
124
+ * it cannot be left as-is in the sibling rule: in a comma-separated selector
125
+ * list, one invalid selector causes the browser to discard the entire rule —
126
+ * breaking sibling selectors that were successfully rewritten.
127
+ *
128
+ * - In a selector list → prune the :volume-locked branch
129
+ * - Lone selector → list becomes empty (caller skips injection)
130
+ * - Inside :is()/:where() → prune from argument list
131
+ * - Inside :not() → rewrite to class selector (matches everything, consistent)
132
+ */
133
+ function handleVolumeLocked(selectorList) {
134
+ let rewrote = false;
135
+ walk(selectorList, {
136
+ visit: "PseudoClassSelector",
137
+ enter(node) {
138
+ if (node.name !== "not" || !node.children) return;
139
+ walk(node, {
140
+ visit: "PseudoClassSelector",
141
+ enter(inner, innerItem, innerList) {
142
+ if (inner.name !== "volume-locked") return;
143
+ rewrote = true;
144
+ const replacement = innerList.createItem({
145
+ type: "ClassSelector",
146
+ name: `${CLASS_PREFIX}volume-locked`,
147
+ loc: inner.loc
148
+ });
149
+ innerList.replace(innerItem, replacement);
150
+ }
151
+ });
152
+ }
153
+ });
154
+ walk(selectorList, {
155
+ visit: "PseudoClassSelector",
156
+ enter(node) {
157
+ if (node.name !== "is" && node.name !== "where") return;
158
+ if (!node.children) return;
159
+ const nestedSelectorList = node.children.first;
160
+ if (nestedSelectorList?.type === "SelectorList") {
161
+ if (pruneSelectorsWithVolumeLocked(nestedSelectorList)) rewrote = true;
162
+ }
163
+ }
164
+ });
165
+ if (pruneSelectorsWithVolumeLocked(selectorList)) rewrote = true;
166
+ return rewrote;
167
+ }
168
+ function containsVolumeLocked(node) {
169
+ let found = false;
170
+ walk(node, {
171
+ visit: "PseudoClassSelector",
172
+ enter(node) {
173
+ if (node.name === "volume-locked") {
174
+ found = true;
175
+ return this.break;
176
+ }
177
+ }
178
+ });
179
+ return found;
180
+ }
181
+ function pruneSelectorsWithVolumeLocked(selectorList) {
182
+ const toRemove = [];
183
+ selectorList.children.forEach((selector, selectorItem) => {
184
+ if (containsVolumeLocked(selector)) toRemove.push(selectorItem);
185
+ });
186
+ for (const selectorItem of toRemove) selectorList.children.remove(selectorItem);
187
+ return toRemove.length > 0;
188
+ }
189
+ /**
190
+ * Process a single inline <style> element.
191
+ * If it contains target pseudo-classes, replaces its content
192
+ * with the rewritten CSS (originals plus class-based siblings).
193
+ */
194
+ function rewriteSingleStyleElement(style, unsupported) {
195
+ const cssText = style.textContent;
196
+ if (!cssText) return;
197
+ const rewritten = rewriteCss(cssText, unsupported);
198
+ if (rewritten === null) return;
199
+ style.textContent = rewritten;
200
+ style.setAttribute("data-polyfill-rewritten", "");
201
+ }
202
+ /**
203
+ * Process all inline <style> elements in the document.
204
+ * For each that contains target pseudo-classes, replaces its content
205
+ * with the rewritten CSS (originals plus class-based siblings).
206
+ */
207
+ function rewriteStyleElements(unsupported) {
208
+ const styles = document.querySelectorAll("style:not([data-polyfill-rewritten])");
209
+ for (const style of styles) rewriteSingleStyleElement(style, unsupported);
210
+ }
211
+ //#endregion
212
+ //#region src/rewrite-link.ts
213
+ /**
214
+ * Check whether a stylesheet URL is same-origin. The browser resolves
215
+ * relative hrefs to absolute URLs on the HTMLLinkElement.href property,
216
+ * so comparing the origin portion covers both relative paths and
217
+ * absolute same-origin URLs while filtering out external CDN links.
218
+ */
219
+ function isSameOrigin(href) {
220
+ try {
221
+ return new URL(href).origin === window.location.origin;
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+ /**
227
+ * Fetch CSS text from a linked stylesheet, rewrite unsupported media
228
+ * pseudo-classes to class-based selectors using the same text-based
229
+ * sibling-injection strategy as inline <style> elements, then inject
230
+ * the rewritten CSS as a <style> element and disable the original link.
231
+ *
232
+ * This avoids the CSSOM approach where the browser silently drops rules
233
+ * with unrecognised pseudo-class selectors before the polyfill can
234
+ * process them.
235
+ */
236
+ async function processLinkSheet(link, unsupported) {
237
+ if (link.hasAttribute("data-polyfill-rewritten")) return;
238
+ const href = link.href;
239
+ if (!href || !isSameOrigin(href)) return;
240
+ let cssText;
241
+ try {
242
+ cssText = await (await fetch(href)).text();
243
+ } catch {
244
+ return;
245
+ }
246
+ const rewritten = rewriteCss(cssText, unsupported);
247
+ link.setAttribute("data-polyfill-rewritten", "");
248
+ if (rewritten === null) return;
249
+ const style = document.createElement("style");
250
+ style.textContent = rewritten;
251
+ style.setAttribute("data-polyfill-rewritten", "");
252
+ link.after(style);
253
+ link.disabled = true;
254
+ }
255
+ /**
256
+ * Process all <link rel="stylesheet"> elements in the document.
257
+ * For each same-origin link, fetches the CSS text, rewrites it, and
258
+ * injects the result as a sibling <style> element.
259
+ *
260
+ * @param unsupported - The set of pseudo-class names that need polyfilling.
261
+ */
262
+ function rewriteLinkStylesheets(unsupported) {
263
+ const links = document.querySelectorAll("link[rel=\"stylesheet\"]:not([data-polyfill-rewritten])");
264
+ for (const link of links) processLinkSheet(link, unsupported);
265
+ }
266
+ //#endregion
267
+ //#region src/state.ts
268
+ /**
269
+ * Computes the current set of pseudo-class states for a media element
270
+ * based on its playback, network, and user-interaction properties.
271
+ *
272
+ * @param element - The media element to inspect.
273
+ * @param isStalledFlag - Whether a `stalled` event has fired since the last
274
+ * `progress` event, indicating the browser has not received new data.
275
+ * @returns A set of pseudo-class name strings (e.g. "paused", "playing",
276
+ * "buffering", "stalled", "seeking", "muted") derived from the element's
277
+ * properties at the time of the call.
278
+ */
279
+ function computeStates(element, isStalledFlag) {
280
+ const states = /* @__PURE__ */ new Set();
281
+ if (element.paused) states.add("paused");
282
+ else {
283
+ states.add("playing");
284
+ if (element.networkState === element.NETWORK_LOADING && element.readyState <= element.HAVE_CURRENT_DATA) {
285
+ states.add("buffering");
286
+ if (isStalledFlag) states.add("stalled");
287
+ }
288
+ }
289
+ if (element.seeking) states.add("seeking");
290
+ if (element.muted) states.add("muted");
291
+ return states;
292
+ }
293
+ //#endregion
294
+ //#region src/observe.ts
295
+ const elementStates = /* @__PURE__ */ new WeakMap();
296
+ function isMediaElement(node) {
297
+ const tagName = node.tagName;
298
+ return tagName === "VIDEO" || tagName === "AUDIO";
299
+ }
300
+ function computeAndApply(element, unsupported) {
301
+ const state = elementStates.get(element);
302
+ if (!state) return;
303
+ const activeStates = computeStates(element, state.isCurrentlyStalled);
304
+ for (const name of PSEUDO_CLASSES) {
305
+ if (!unsupported.has(name)) continue;
306
+ const className = `${CLASS_PREFIX}${name}`;
307
+ element.classList.toggle(className, activeStates.has(name));
308
+ }
309
+ }
310
+ function attachListeners(element, unsupported) {
311
+ if (elementStates.has(element)) return;
312
+ const state = {
313
+ handler: () => {},
314
+ isCurrentlyStalled: false
315
+ };
316
+ const handler = (event) => {
317
+ switch (event.type) {
318
+ case "stalled":
319
+ state.isCurrentlyStalled = true;
320
+ break;
321
+ case "progress":
322
+ case "emptied":
323
+ case "loadstart":
324
+ state.isCurrentlyStalled = false;
325
+ break;
326
+ }
327
+ computeAndApply(element, unsupported);
328
+ };
329
+ state.handler = handler;
330
+ elementStates.set(element, state);
331
+ for (const eventType of MEDIA_EVENTS) element.addEventListener(eventType, handler);
332
+ computeAndApply(element, unsupported);
333
+ }
334
+ function detachListeners(element) {
335
+ const state = elementStates.get(element);
336
+ if (!state) return;
337
+ for (const eventType of MEDIA_EVENTS) element.removeEventListener(eventType, state.handler);
338
+ elementStates.delete(element);
339
+ }
340
+ function discoverMediaElements(node, unsupported) {
341
+ if (isMediaElement(node)) attachListeners(node, unsupported);
342
+ if (node.nodeType === 1) {
343
+ const mediaElements = node.querySelectorAll("audio, video");
344
+ for (const mediaElement of mediaElements) attachListeners(mediaElement, unsupported);
345
+ }
346
+ }
347
+ function cleanupMediaElements(node) {
348
+ if (isMediaElement(node)) detachListeners(node);
349
+ if (node.nodeType === 1) {
350
+ const mediaElements = node.querySelectorAll("audio, video");
351
+ for (const mediaElement of mediaElements) detachListeners(mediaElement);
352
+ }
353
+ }
354
+ /**
355
+ * Discovers media elements in the DOM, attaches event listeners, and
356
+ * toggles polyfill classes based on computed state. Sets up a
357
+ * MutationObserver to handle dynamically added and removed elements.
358
+ *
359
+ * @param unsupported - The set of pseudo-class names that need polyfilling.
360
+ */
361
+ function observeMediaElements(unsupported) {
362
+ const existingElements = document.querySelectorAll("audio, video");
363
+ for (const element of existingElements) attachListeners(element, unsupported);
364
+ new MutationObserver((mutations) => {
365
+ for (const mutation of mutations) {
366
+ for (const addedNode of mutation.addedNodes) discoverMediaElements(addedNode, unsupported);
367
+ for (const removedNode of mutation.removedNodes) cleanupMediaElements(removedNode);
368
+ }
369
+ }).observe(document.documentElement, {
370
+ childList: true,
371
+ subtree: true
372
+ });
373
+ }
374
+ //#endregion
375
+ //#region src/observe-stylesheets.ts
376
+ /**
377
+ * Tracks <style> elements currently being rewritten by the polyfill.
378
+ * Used to distinguish polyfill-initiated textContent changes from
379
+ * author-initiated ones, preventing infinite mutation loops.
380
+ */
381
+ const rewritingInProgress = /* @__PURE__ */ new WeakSet();
382
+ /**
383
+ * Check whether a node is an Element (nodeType 1).
384
+ */
385
+ function isElement(node) {
386
+ return node.nodeType === 1;
387
+ }
388
+ /**
389
+ * Check whether an element is a <style> element.
390
+ */
391
+ function isStyleElement(element) {
392
+ return element.tagName === "STYLE";
393
+ }
394
+ /**
395
+ * Check whether an element is a <link rel="stylesheet">.
396
+ */
397
+ function isStylesheetLink(element) {
398
+ return element.tagName === "LINK" && element.getAttribute("rel") === "stylesheet";
399
+ }
400
+ /**
401
+ * Rewrite a <style> element, guarding against mutation loops via the
402
+ * rewritingInProgress WeakSet.
403
+ */
404
+ function rewriteStyleWithGuard(style, unsupported) {
405
+ rewritingInProgress.add(style);
406
+ rewriteSingleStyleElement(style, unsupported);
407
+ }
408
+ /**
409
+ * Process a single added node: if it is (or contains) stylesheet elements,
410
+ * rewrite them.
411
+ */
412
+ function processAddedNode(node, unsupported) {
413
+ if (!isElement(node)) return;
414
+ if (isStyleElement(node) && !node.hasAttribute("data-polyfill-rewritten")) rewriteStyleWithGuard(node, unsupported);
415
+ else if (isStylesheetLink(node) && !node.hasAttribute("data-polyfill-rewritten")) processLinkElement(node, unsupported);
416
+ for (const style of node.querySelectorAll("style:not([data-polyfill-rewritten])")) rewriteStyleWithGuard(style, unsupported);
417
+ for (const link of node.querySelectorAll("link[rel=\"stylesheet\"]:not([data-polyfill-rewritten])")) processLinkElement(link, unsupported);
418
+ }
419
+ /**
420
+ * Handle a childList mutation whose target is a <style> element.
421
+ * This fires when textContent is replaced (old Text child removed, new added).
422
+ */
423
+ function handleStyleChildListMutation(style, unsupported) {
424
+ if (rewritingInProgress.has(style)) {
425
+ rewritingInProgress.delete(style);
426
+ return;
427
+ }
428
+ style.removeAttribute("data-polyfill-rewritten");
429
+ rewriteStyleWithGuard(style, unsupported);
430
+ }
431
+ /**
432
+ * Handle a characterData mutation on a text node inside a <style> element.
433
+ * This fires when the author modifies the text node directly
434
+ * (e.g., style.firstChild.data = "...").
435
+ */
436
+ function handleCharacterDataMutation(target, unsupported) {
437
+ const parent = target.parentElement;
438
+ if (!parent || !isStyleElement(parent)) return;
439
+ if (rewritingInProgress.has(parent)) {
440
+ rewritingInProgress.delete(parent);
441
+ return;
442
+ }
443
+ parent.removeAttribute("data-polyfill-rewritten");
444
+ rewriteStyleWithGuard(parent, unsupported);
445
+ }
446
+ /**
447
+ * Process a <link rel="stylesheet"> element. processLinkSheet handles
448
+ * fetching the CSS text directly, so no load-event deferral is needed.
449
+ */
450
+ function processLinkElement(link, unsupported) {
451
+ processLinkSheet(link, unsupported);
452
+ }
453
+ /**
454
+ * Observe the DOM for dynamically added or mutated stylesheets and
455
+ * rewrite them to polyfill unsupported media pseudo-classes.
456
+ */
457
+ function observeStylesheets(unsupported) {
458
+ new MutationObserver((records) => {
459
+ for (const record of records) if (record.type === "childList") {
460
+ const target = record.target;
461
+ if (isElement(target) && isStyleElement(target) && target.hasAttribute("data-polyfill-rewritten")) {
462
+ handleStyleChildListMutation(target, unsupported);
463
+ continue;
464
+ }
465
+ for (const node of record.addedNodes) processAddedNode(node, unsupported);
466
+ } else if (record.type === "characterData") handleCharacterDataMutation(record.target, unsupported);
467
+ else if (record.type === "attributes") {
468
+ const target = record.target;
469
+ if (isElement(target) && isStylesheetLink(target)) {
470
+ target.removeAttribute("data-polyfill-rewritten");
471
+ processLinkSheet(target, unsupported);
472
+ }
473
+ }
474
+ }).observe(document.documentElement, {
475
+ childList: true,
476
+ subtree: true,
477
+ characterData: true,
478
+ attributes: true,
479
+ attributeFilter: ["href"]
480
+ });
481
+ }
482
+ //#endregion
483
+ //#region src/polyfill.ts
484
+ function detectUnsupported() {
485
+ const unsupported = /* @__PURE__ */ new Set();
486
+ if (typeof CSS === "undefined" || typeof CSS.supports !== "function") return new Set(PSEUDO_CLASSES);
487
+ for (const name of PSEUDO_CLASSES) try {
488
+ if (!CSS.supports(`selector(:${name})`)) unsupported.add(name);
489
+ } catch {
490
+ unsupported.add(name);
491
+ }
492
+ return unsupported;
493
+ }
494
+ function polyfill() {
495
+ const unsupported = detectUnsupported();
496
+ if (unsupported.size === 0) return;
497
+ rewriteStyleElements(unsupported);
498
+ rewriteLinkStylesheets(unsupported);
499
+ observeMediaElements(unsupported);
500
+ observeStylesheets(unsupported);
501
+ }
502
+ //#endregion
503
+ export { polyfill as t };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@schalkneethling/css-media-pseudo-polyfill",
3
+ "version": "1.0.2",
4
+ "description": "A CSS polyfill for media pseudo-classes (:playing, :paused, :seeking, :buffering, :stalled, :muted)",
5
+ "keywords": [
6
+ "audio",
7
+ "buffering",
8
+ "css",
9
+ "media",
10
+ "muted",
11
+ "paused",
12
+ "playing",
13
+ "polyfill",
14
+ "pseudo-classes",
15
+ "seeking",
16
+ "video"
17
+ ],
18
+ "homepage": "https://github.com/schalkneethling/css-media-pseudo-polyfill",
19
+ "bugs": {
20
+ "url": "https://github.com/schalkneethling/css-media-pseudo-polyfill/issues"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Schalk Neethling",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/schalkneethling/css-media-pseudo-polyfill"
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "type": "module",
32
+ "exports": {
33
+ ".": "./dist/index.mjs",
34
+ "./fn": "./dist/fn.mjs",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "dependencies": {
38
+ "css-tree": "^3.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "@playwright/test": "^1.52.0",
42
+ "@types/css-tree": "^2.3.10",
43
+ "@types/node": "^25.5.0",
44
+ "@typescript/native-preview": "7.0.0-dev.20260316.1",
45
+ "bumpp": "^11.0.1",
46
+ "typescript": "^5.9.3",
47
+ "vite": "latest",
48
+ "vite-plus": "latest",
49
+ "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
50
+ },
51
+ "scripts": {
52
+ "build": "vp pack",
53
+ "dev": "vp pack --watch",
54
+ "test": "vp test",
55
+ "test:e2e": "playwright test",
56
+ "typecheck": "tsc --noEmit",
57
+ "release": "bumpp"
58
+ }
59
+ }