diff --git a/package-lock.json b/package-lock.json index e0d043f9..29c2de79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "atomico", "version": "1.74.3", "license": "MIT", + "dependencies": { + "csstype": "^3.1.2" + }, "devDependencies": { "@esm-bundle/chai": "^4.3.4-fix.0", "@rollup/plugin-node-resolve": "^13.3.0", @@ -1323,6 +1326,11 @@ "node-fetch": "2.6.7" } }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + }, "node_modules/debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", @@ -4729,6 +4737,11 @@ "node-fetch": "2.6.7" } }, + "csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + }, "debounce": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", diff --git a/package.json b/package.json index 1c52f446..cde185c6 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,9 @@ "url": "https://github.com/atomicojs/atomico/issues" }, "homepage": "https://github.com/atomicojs/atomico#readme", + "dependencies": { + "csstype": "^3.1.2" + }, "devDependencies": { "@esm-bundle/chai": "^4.3.4-fix.0", "@rollup/plugin-node-resolve": "^13.3.0", diff --git a/src/element/set-prototype.js b/src/element/set-prototype.js index 58afd888..64a979ce 100644 --- a/src/element/set-prototype.js +++ b/src/element/set-prototype.js @@ -1,4 +1,4 @@ -import { isFunction, isObject } from "../utils.js"; +import { isFunction, isNumber, isObject } from "../utils.js"; import { PropError } from "./errors.js"; export const CUSTOM_TYPE_NAME = "Custom"; @@ -192,7 +192,7 @@ export const filterValue = (type, value) => value, error: type == Number - ? typeof value != "number" + ? !isNumber(value) ? true : Number.isNaN(value) : type == String diff --git a/src/render.js b/src/render.js index 258b85ca..d5cdd9ee 100644 --- a/src/render.js +++ b/src/render.js @@ -1,4 +1,11 @@ -import { isFunction, isObject, isArray, flat, isHydrate } from "./utils.js"; +import { + isFunction, + isObject, + isArray, + flat, + isHydrate, + isNumber, +} from "./utils.js"; import { options } from "./options.js"; // Object used to know which properties are extracted directly // from the node to verify 2 if they have changed @@ -31,6 +38,11 @@ const INTERNAL_PROPS = { const EMPTY_PROPS = {}; // Immutable for empty children comparison const EMPTY_CHILDREN = []; +/** + * @see https://github.com/preactjs/preact/blob/main/src/constants.js + */ +export const IS_NON_DIMENSIONAL = + /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i; // Alias for document export const $ = document; // Fragment marker @@ -303,7 +315,7 @@ export function renderChildren(children, fragment, parent, id, hydrate, isSvg) { children && flat(children, (child) => { - if (typeof child == "object" && child.$$ != $$) { + if (isObject(child) && child.$$ != $$) { return; } @@ -526,7 +538,7 @@ export function setEvent(node, type, nextHandler, handlers) { * * @param {*} style * @param {string} key - * @param {string} value + * @param {string|number} value */ export function setPropertyStyle(style, key, value) { let method = "setProperty"; @@ -534,10 +546,12 @@ export function setPropertyStyle(style, key, value) { method = "removeProperty"; value = null; } - if (~key.indexOf("-")) { + if (key[0] === "-") { style[method](key, value); - } else { + } else if (!isNumber(value) || IS_NON_DIMENSIONAL.test(key)) { style[key] = value; + } else { + style[key] = `${value}px`; } } diff --git a/src/tests/render-internals.test.js b/src/tests/render-internals.test.js index d8d5e4c4..899df1fd 100644 --- a/src/tests/render-internals.test.js +++ b/src/tests/render-internals.test.js @@ -15,7 +15,7 @@ describe("src/render#setEvent", () => { //@ts-ignore setEvent(container, "click", handler, handlers); container.click(); - + // @ts-ignore setEvent(container, "click", null, handlers); container.click(); @@ -31,10 +31,30 @@ describe("src/render#setPropertyStyle", () => { expect(container.style.width).to.equal("100px"); + setPropertyStyle(container.style, "width", 100); + + expect(container.style.width).to.equal("100px"); + setPropertyStyle(container.style, "width", null); expect(container.style.width).to.equal(""); + setPropertyStyle(container.style, "lineHeight", 1); + + expect(container.style.lineHeight).to.equal("1"); + + setPropertyStyle(container.style, "float", "left"); + + expect(container.style.float).to.equal("left"); + + setPropertyStyle(container.style, "flex", 1); + + expect(container.style.flex).to.equal("1 1 0%"); + + setPropertyStyle(container.style, "WebkitLineClamp", 2); + + expect(container.style.webkitLineClamp).to.equal("2"); + setPropertyStyle(container.style, "--my-custom-property", "red"); expect( @@ -50,10 +70,12 @@ describe("src/render#diffProps", () => { const nextProps = { class: "my-class" }; const handlers = {}; + // @ts-ignore diffProps(container, props, nextProps, handlers, false); expect(container.className).to.equal("my-class"); + // @ts-ignore diffProps(container, nextProps, props, handlers, false); expect(container.className).to.equal(""); @@ -77,7 +99,6 @@ describe("src/render#setProperty", () => { it("setProperty#style", () => { const container = document.createElement("div"); const handlers = {}; - //@ts-ignore setProperty( container, "style", @@ -86,6 +107,7 @@ describe("src/render#setProperty", () => { width: "100px", }, false, + // @ts-ignore handlers ); diff --git a/src/utils.js b/src/utils.js index bda4dc71..18fa4f78 100644 --- a/src/utils.js +++ b/src/utils.js @@ -32,6 +32,12 @@ export const isObject = (value) => typeof value == "object"; export const { isArray } = Array; +/** + * @param {any} value + * @returns {value is number} + */ +export const isNumber = (value) => typeof value == "number"; + /** * * @param {Element & {dataset?:object}} node @@ -53,17 +59,16 @@ export function flat(list, callback) { let { length } = list; for (let i = 0; i < length; i++) { const value = list[i]; - if (value && Array.isArray(value)) { + if (value && isArray(value)) { reduce(value); } else { - const type = typeof value; if ( value == null || - type === "function" || - type === "boolean" + isFunction(value) || + typeof value === "boolean" ) { continue; - } else if (type === "string" || type === "number") { + } else if (typeof value === "string" || isNumber(value)) { if (last == null) last = ""; last += value; } else { diff --git a/tests/utils.test.js b/tests/utils.test.js index e79bdc10..138297c7 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -1,22 +1,42 @@ import { expect } from "@esm-bundle/chai"; -import { serialize, checkIncompatibility } from "../utils"; +import { serialize, checkIncompatibility, toCss } from "../utils"; describe("utils", () => { it("serialize", () => { expect(serialize(true && "1", true && "2", true && "3")).to.equal( - "1 2 3" + "1 2 3", ); expect(serialize(false && "1", true && "2", true && "3")).to.equal( - "2 3" + "2 3", ); expect(serialize(false && "1", true && "2", false && "3")).to.equal( - "2" + "2", ); }); it("checkIncompatibility", () => { expect(checkIncompatibility()).to.instanceOf(Array); expect(checkIncompatibility().length).to.equal(0); }); + it("toCss", () => { + const extract = (s) => Object.values(s.cssRules).map((v) => v.cssText); + let styleSheet = toCss({ + ":host": { + width: 696, + height: 100, + flex: 1, + }, + ".root": { + fontSize: 12, + lineHeight: 1.5, + }, + }); + expect(extract(styleSheet)).to.eql([ + ":host { width: 696px; height: 100px; flex: 1 1 0%; }", + ".root { font-size: 12px; line-height: 1.5; }", + ]); + styleSheet = toCss("https://unpkg.com/open-props"); + expect(extract(styleSheet)).to.have.length.gt(1); + }); }); diff --git a/types/dom.d.ts b/types/dom.d.ts index bc4190f9..48cb9354 100644 --- a/types/dom.d.ts +++ b/types/dom.d.ts @@ -1,3 +1,4 @@ +import * as CSS from "csstype"; import { SVGProperties } from "./dom-svg.js"; import { DOMFormElements, DOMFormElement } from "./dom-html.js"; import { Sheets, Sheet } from "./css.js"; @@ -19,7 +20,7 @@ type DOMRef = { }; interface DOMGenericProperties { - style?: string | Partial | object; + style?: string | CSS.Properties; class?: string; id?: string; slot?: string; diff --git a/types/utils.d.ts b/types/utils.d.ts index 0f3a8c46..96718642 100644 --- a/types/utils.d.ts +++ b/types/utils.d.ts @@ -1,3 +1,6 @@ +import * as CSS from "csstype"; +import { Sheet } from "./css.js"; + /** * Filter the parameters and join in a string only those that are considered different from * `"" | false | 0 | null | undefined`. @@ -12,3 +15,8 @@ export function serialize(...args: any): string; * check Atomico's leveraged compatibility with the current browser */ export function checkIncompatibility(): string[]; + +export function toCss(obj: { + [key: string]: CSS.Properties; +}): Sheet; +export function toCss(obj: string): Sheet | undefined; diff --git a/utils.js b/utils.js index 3ee11011..37887baf 100644 --- a/utils.js +++ b/utils.js @@ -1,3 +1,7 @@ +import { css } from "./src/css.js"; +import { IS_NON_DIMENSIONAL } from "./src/render.js"; +import { isNumber, isObject } from "./src/utils.js"; + const W = globalThis; const COMPATIBILITY_LIST = [ @@ -26,3 +30,63 @@ export const checkIncompatibility = () => //@ts-ignore .map(([check, ctx]) => (!ctx || !(check in ctx) ? check : 0)) .filter((check) => check); + +/** + * @type {{[id:string]:string}} + */ +const PROPS = {}; + +/** + * @param {string} str + */ +const hyphenate = (str) => + (PROPS[str] = + PROPS[str] || + str + .replace(/([A-Z])/g, "-$1") + .toLowerCase() + .replace(/^ms-/, "-ms-")); + +/** + * @param {object} obj + * @returns {string} + */ +const stringify = (obj) => + Object.entries(obj) + .map(([key, value]) => { + if (isObject(value)) { + return `${key}{${stringify(value)};}`.replace(/;;/g, ";"); + } + if (key[0] === "-") { + return `${key}:${value}`; + } + return `${hyphenate(key)}:${ + !isNumber(value) || IS_NON_DIMENSIONAL.test(key) + ? value + : `${value}px` + }`; + }) + .join(";") + .replace(/};/g, "}"); + +/** + * Create a Style from an object + * @param {{[key:string]:import("csstype").Properties}|string} obj + * @returns {import("./types/css.js").Sheet | undefined} + */ +export function toCss(obj) { + if (typeof obj === "string") { + if (obj.match(/^https?:\/\//)) { + const request = new XMLHttpRequest(); + request.open("get", obj, false); + request.send(); + if (request.status === 200) + return css` + ${request.responseText} + `; + } + } + return css` + ${stringify(obj)} + `; +}