UNPKG

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
// 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 };