container-query-polyfill
Version:
A tiny polyfill for [CSS Container Queries][mdn], weighing about 1.6kB brotli’d. It transpiles CSS code on the client-side and implements Container Query functionality using [ResizeObserver] and [MutationObserver].
367 lines (365 loc) • 10.5 kB
JavaScript
// src/engine.ts
function uid() {
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 256).toString(16)).join("");
}
var Measurement;
(function(Measurement2) {
Measurement2[Measurement2["MinWidth"] = 0] = "MinWidth";
Measurement2[Measurement2["MaxWidth"] = 1] = "MaxWidth";
Measurement2[Measurement2["MinHeight"] = 2] = "MinHeight";
Measurement2[Measurement2["MaxHeight"] = 3] = "MaxHeight";
})(Measurement || (Measurement = {}));
var comparators = new Map([
[3, (v, t) => v.blockSize <= t],
[2, (v, t) => v.blockSize >= t],
[1, (v, t) => v.inlineSize <= t],
[0, (v, t) => v.inlineSize >= t]
]);
function isQueryFullfilled(breakpoint, entry) {
let borderBox;
if ("borderBoxSize" in entry) {
borderBox = entry.borderBoxSize?.[0] ?? entry.borderBoxSize;
} else {
const computed = getComputedStyle(entry.target);
borderBox = {
blockSize: entry.contentRect.height,
inlineSize: entry.contentRect.width
};
borderBox.blockSize += parseInt(computed.paddingBlockStart.slice(0, -2)) + parseInt(computed.paddingBlockEnd.slice(0, -2));
borderBox.inlineSize += parseInt(computed.paddingInlineStart.slice(0, -2)) + parseInt(computed.paddingInlineEnd.slice(0, -2));
}
return comparators.get(breakpoint.measurement)(borderBox, breakpoint.threshold);
}
function findParentContainer(el, name) {
while (el) {
el = el.parentElement;
if (!containerNames.has(el))
continue;
if (name) {
const containerName = containerNames.get(el);
if (!containerName.includes(name))
continue;
}
return el;
}
return null;
}
var containerNames = new WeakMap();
function registerContainer(el, name) {
containerRO.observe(el);
if (!containerNames.has(el)) {
containerNames.set(el, []);
}
containerNames.get(el).push(name);
}
var queries = [];
function registerContainerQuery(cqd) {
queries.push(cqd);
}
var containerRO = new ResizeObserver((entries) => {
const changedContainers = new Map(entries.map((entry) => [entry.target, entry]));
for (const query of queries) {
for (const { selector } of query.rules) {
const els = document.querySelectorAll(selector);
for (const el of els) {
const container = findParentContainer(el, query.name);
if (!container)
continue;
if (!changedContainers.has(container))
continue;
const entry = changedContainers.get(container);
el.classList.toggle(query.className, isQueryFullfilled(query.breakPoint, entry));
}
}
}
});
var watchedContainerSelectors = [];
var containerMO = new MutationObserver((entries) => {
for (const entry of entries) {
for (const node of entry.removedNodes) {
if (!(node instanceof HTMLElement))
continue;
containerRO.unobserve(node);
}
for (const node of entry.addedNodes) {
if (!(node instanceof HTMLElement))
continue;
for (const watchedContainerSelector of watchedContainerSelectors) {
if (node.matches(watchedContainerSelector.selector)) {
registerContainer(node, watchedContainerSelector.name);
}
}
}
}
});
containerMO.observe(document.documentElement, {
childList: true,
subtree: true
});
function transpileStyleSheet(sheetSrc) {
const p = {
sheetSrc,
index: 0
};
while (true) {
eatWhitespace(p);
if (p.index >= p.sheetSrc.length)
break;
while (lookAhead("/*", p)) {
eatComment(p);
eatWhitespace(p);
}
if (lookAhead("@container", p)) {
const { query, startIndex, endIndex } = parseContainerQuery(p);
const replacement = stringifyContainerQuery(query);
replacePart(startIndex, endIndex, replacement, p);
registerContainerQuery(query);
} else {
const rule = parseRule(p);
if (!rule)
continue;
handleContainerProps(rule, p);
}
}
return p.sheetSrc;
}
function handleContainerProps(rule, p) {
const hasLongHand = rule.block.contents.includes("container-");
const hasShortHand = rule.block.contents.includes("container:");
if (!hasLongHand && !hasShortHand)
return;
let containerName, containerType;
if (hasLongHand) {
containerName = /container-name: ([^;]+);/.exec(rule.block.contents)?.[1];
rule.block.contents = rule.block.contents.replace("container-type", "contain");
}
if (hasShortHand) {
const containerShorthand = /container: ([^;]+);/.exec(rule.block.contents)?.[1];
[containerType, containerName] = containerShorthand.split("/").map((v) => v.trim());
rule.block.contents = rule.block.contents.replace(/container: ([^;]+);/, `contain: ${containerType};`);
}
if (!containerName) {
containerName = uid();
}
replacePart(rule.block.startIndex, rule.block.endIndex, rule.block.contents, p);
watchedContainerSelectors.push({
name: containerName,
selector: rule.selector
});
for (const el of document.querySelectorAll(rule.selector)) {
registerContainer(el, containerName);
}
}
function replacePart(start, end, replacement, p) {
p.sheetSrc = p.sheetSrc.slice(0, start) + replacement + p.sheetSrc.slice(end);
if (p.index >= end) {
const delta = p.index - end;
p.index = start + replacement.length + delta;
}
}
function eatComment(p) {
assertString(p, "/*");
eatUntil("*/", p);
assertString(p, "*/");
}
function advance(p) {
p.index++;
if (p.index >= p.sheetSrc.length) {
throw Error("Advanced beyond the end");
}
}
function eatUntil(s, p) {
const startIndex = p.index;
while (!lookAhead(s, p)) {
advance(p);
}
return p.sheetSrc.slice(startIndex, p.index);
}
function lookAhead(s, p) {
return p.sheetSrc.substr(p.index, s.length) == s;
}
function parseSelector(p) {
let startIndex = p.index;
while (/[\sa-zA-Z0-9:_\.,()#\[\]=+~*-]/.test(p.sheetSrc[p.index])) {
advance(p);
}
if (!lookAhead("{", p)) {
eatUntil("\n", p);
eatWhitespace(p);
return;
}
return p.sheetSrc.slice(startIndex, p.index);
}
function parseRule(p) {
const startIndex = p.index;
const selector = parseSelector(p);
if (!selector)
return;
const block = eatBlock(p);
const endIndex = p.index;
return {
selector,
block,
startIndex,
endIndex
};
}
function assertString(p, s) {
if (p.sheetSrc.substr(p.index, s.length) != s) {
throw Error(`Did not find expected sequence ${s}`);
}
p.index += s.length;
}
var whitespaceMatcher = /\s*/g;
function eatWhitespace(p) {
whitespaceMatcher.lastIndex = p.index;
const match = whitespaceMatcher.exec(p.sheetSrc);
if (match) {
p.index += match[0].length;
}
}
function peek(p) {
return p.sheetSrc[p.index];
}
var identMatcher = /[\w\@_-]+/g;
function parseIdentifier(p) {
identMatcher.lastIndex = p.index;
const match = identMatcher.exec(p.sheetSrc);
if (!match) {
throw Error("Expected an identifier");
}
p.index += match[0].length;
return match[0];
}
function undashify(s) {
const v = s.replace(/-(\w)/, (_, l) => l.toUpperCase()).replace(/^\w/, (v2) => v2.toUpperCase());
return v;
}
function parseMeasurementName(p) {
const measurementName = undashify(parseIdentifier(p).toLowerCase());
if (!(measurementName in Measurement)) {
throw Error(`Unknown query ${measurementName}`);
}
return Measurement[measurementName];
}
var numberMatcher = /[0-9.]*/g;
function parseThreshold(p) {
numberMatcher.lastIndex = p.index;
const match = numberMatcher.exec(p.sheetSrc);
if (!match) {
throw Error("Expected a number");
}
p.index += match[0].length;
assertString(p, "px");
const value = parseFloat(match[0]);
if (Number.isNaN(value)) {
throw Error(`${match[0]} is not a valid number`);
}
return value;
}
function eatBlock(p) {
const startIndex = p.index;
assertString(p, "{");
let level = 1;
while (level != 0) {
if (p.sheetSrc[p.index] === "{") {
level++;
} else if (p.sheetSrc[p.index] === "}") {
level--;
}
advance(p);
}
const endIndex = p.index;
const contents = p.sheetSrc.slice(startIndex, endIndex);
return { startIndex, endIndex, contents };
}
function parseContainerQuery(p) {
const startIndex = p.index;
assertString(p, "@container");
eatWhitespace(p);
let name = "";
if (peek(p) !== "(") {
name = parseIdentifier(p);
eatWhitespace(p);
}
assertString(p, "(");
eatWhitespace(p);
const measurement = parseMeasurementName(p);
eatWhitespace(p);
assertString(p, ":");
eatWhitespace(p);
const threshold = parseThreshold(p);
eatWhitespace(p);
assertString(p, ")");
eatWhitespace(p);
assertString(p, "{");
eatWhitespace(p);
const rules = [];
while (peek(p) !== "}") {
rules.push(parseRule(p));
eatWhitespace(p);
}
assertString(p, "}");
const endIndex = p.index;
eatWhitespace(p);
const className = `cq_${uid()}`;
return {
query: {
breakPoint: {
measurement,
threshold
},
className,
name,
rules
},
startIndex,
endIndex
};
}
function stringifyContainerQuery(query) {
return query.rules.map((rule) => `:is(${rule.selector}).${query.className} ${rule.block.contents}`).join("\n");
}
// src/cqfill.ts
function init() {
const sheetObserver = new MutationObserver((entries) => {
for (const entry of entries) {
for (const addedNode of entry.addedNodes) {
if (addedNode instanceof HTMLStyleElement) {
handleStyleTag(addedNode);
}
if (addedNode instanceof HTMLLinkElement) {
handleLinkedStylesheet(addedNode);
}
}
}
});
sheetObserver.observe(document.documentElement, {
childList: true,
subtree: true
});
function handleStyleTag(el) {
const newSrc = transpileStyleSheet(el.innerHTML);
el.innerHTML = newSrc;
}
async function handleLinkedStylesheet(el) {
if (el.rel !== "stylesheet")
return;
const srcUrl = new URL(el.href, import.meta.url);
if (srcUrl.origin !== location.origin)
return;
const src = await fetch(srcUrl.toString()).then((r) => r.text());
const newSrc = transpileStyleSheet(src);
const blob = new Blob([newSrc], { type: "text/css" });
el.href = URL.createObjectURL(blob);
}
document.querySelectorAll("style").forEach((tag) => handleStyleTag(tag));
document.querySelectorAll("link").forEach((tag) => handleLinkedStylesheet(tag));
}
var supportsContainerQueries = "container" in document.documentElement.style;
if (!supportsContainerQueries) {
init();
}
export {
transpileStyleSheet
};