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].

450 lines (415 loc) 12 kB
/** * Copyright 2021 Google Inc. All Rights Reserved. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * http://www.apache.org/licenses/LICENSE-2.0 * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ interface ContainerQueryDescriptor { name?: string; breakPoint: BreakPoint; className: string; rules: Rule[]; } function uid(): string { return Array.from({ length: 16 }, () => Math.floor(Math.random() * 256).toString(16) ).join(""); } enum Measurement { MinWidth, MaxWidth, MinHeight, MaxHeight, } // TODO: Fix for logical props type Comparator = (v: ResizeObserverSize, threshold: number) => boolean; const comparators: Map<Measurement, Comparator> = new Map([ [Measurement.MaxHeight, (v, t) => v.blockSize <= t], [Measurement.MinHeight, (v, t) => v.blockSize >= t], [Measurement.MaxWidth, (v, t) => v.inlineSize <= t], [Measurement.MinWidth, (v, t) => v.inlineSize >= t], ]); interface BreakPoint { measurement: Measurement; threshold: number; } function isQueryFullfilled( breakpoint: BreakPoint, entry: ResizeObserverEntry ): boolean { let borderBox; if ("borderBoxSize" in entry) { // At the time of writing, the array will always be length one in Chrome. // In Firefox, it won’t be an array, but a single object. borderBox = entry.borderBoxSize?.[0] ?? entry.borderBoxSize; } else { // Safari doesn’t have borderBoxSize at all, but only offers `contentRect`, // so we have to do some maths ourselves. const computed = getComputedStyle(entry.target); borderBox = { // FIXME: This will if you are not in tblr writing mode blockSize: entry.contentRect.height, inlineSize: entry.contentRect.width, }; // Cut off the "px" suffix from the computed styles. 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: Element, name?: string): Element | null { 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; } const containerNames: WeakMap<Element, string[]> = new WeakMap(); function registerContainer(el: Element, name: string) { containerRO.observe(el); if (!containerNames.has(el)) { containerNames.set(el, []); } containerNames.get(el)!.push(name); } const queries: Array<ContainerQueryDescriptor> = []; function registerContainerQuery(cqd: ContainerQueryDescriptor) { queries.push(cqd); } const containerRO = new ResizeObserver((entries) => { const changedContainers: Map<Element, ResizeObserverEntry> = 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) ); } } } }); interface WatchedSelector { selector: string; name: string; } const watchedContainerSelectors: WatchedSelector[] = []; const 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, }); interface AdhocParser { sheetSrc: string; index: number; } export function transpileStyleSheet(sheetSrc: string): string { const p: AdhocParser = { 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: 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: number, end: number, replacement: string, p: AdhocParser ) { p.sheetSrc = p.sheetSrc.slice(0, start) + replacement + p.sheetSrc.slice(end); // If we are pointing past the end of the affected section, we need to // recalculate the string pointer. Pointing to something inside the section // that’s being replaced is undefined behavior. Sue me. if (p.index >= end) { const delta = p.index - end; p.index = start + replacement.length + delta; } } function eatComment(p: AdhocParser) { assertString(p, "/*"); eatUntil("*/", p); assertString(p, "*/"); } function advance(p: AdhocParser) { p.index++; if (p.index >= p.sheetSrc.length) { throw Error("Advanced beyond the end"); } } function eatUntil(s: string, p: AdhocParser): string { const startIndex = p.index; while (!lookAhead(s, p)) { advance(p); } return p.sheetSrc.slice(startIndex, p.index); } function lookAhead(s: string, p: AdhocParser): boolean { return p.sheetSrc.substr(p.index, s.length) == s; } interface Rule { selector: string; block: Block; startIndex: number; endIndex: number; } interface Block { contents: string; startIndex: number; endIndex: number; } function parseSelector(p: AdhocParser): string | undefined { 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: AdhocParser): Rule | undefined { 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: AdhocParser, s: string) { if (p.sheetSrc.substr(p.index, s.length) != s) { throw Error(`Did not find expected sequence ${s}`); } p.index += s.length; } const whitespaceMatcher = /\s*/g; function eatWhitespace(p: AdhocParser) { // Start matching at the current position in the sheet src whitespaceMatcher.lastIndex = p.index; const match = whitespaceMatcher.exec(p.sheetSrc); if (match) { p.index += match[0].length; } } function peek(p: AdhocParser): string { return p.sheetSrc[p.index]; } const identMatcher = /[\w\@_-]+/g; function parseIdentifier(p: AdhocParser): string { 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]; } // This function does stuff like `min-width` => `MinWidth` function undashify(s: string): string { const v = s .replace(/-(\w)/, (_, l) => l.toUpperCase()) .replace(/^\w/, (v) => v.toUpperCase()); return v; } function parseMeasurementName(p: AdhocParser): Measurement { const measurementName = undashify(parseIdentifier(p).toLowerCase()); if (!(measurementName in Measurement)) { throw Error(`Unknown query ${measurementName}`); } // FIXME: lol return Measurement[measurementName as any] as any; } const numberMatcher = /[0-9.]*/g; function parseThreshold(p: AdhocParser): number { numberMatcher.lastIndex = p.index; const match = numberMatcher.exec(p.sheetSrc); if (!match) { throw Error("Expected a number"); } p.index += match[0].length; // TODO: Support other units? 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: AdhocParser): Block { 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 }; } interface ParseResult { query: ContainerQueryDescriptor; startIndex: number; endIndex: number; } function parseContainerQuery(p: AdhocParser): ParseResult { const startIndex = p.index; assertString(p, "@container"); eatWhitespace(p); let name: string = ""; 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: ContainerQueryDescriptor): string { return query.rules .map( (rule) => `:is(${rule.selector}).${query.className} ${rule.block.contents}` ) .join("\n"); }