diff --git a/src/__tests__/applyCssUnits.test.ts b/src/__tests__/applyCssUnits.test.ts new file mode 100644 index 0000000..9a11ca8 --- /dev/null +++ b/src/__tests__/applyCssUnits.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { applyCssUnits } from "../helpers"; +import { type CSSUnitMap } from "../types"; + +describe("applyCssUnits", () => { + it("returns string values as-is", () => { + expect(applyCssUnits("fontSize", "2em")).toBe("2em"); + expect(applyCssUnits("color", "red")).toBe("red"); + }); + + it('returns "0" as-is (without unit)', () => { + expect(applyCssUnits("marginTop", 0)).toBe("0"); + }); + + it("applies default px unit to numeric values", () => { + expect(applyCssUnits("marginTop", 10)).toBe("10px"); + }); + + it("uses custom unit string when provided", () => { + expect(applyCssUnits("fontSize", 1.5, "em")).toBe("1.5em"); + }); + + it("uses unit map when provided", () => { + const unitMap: CSSUnitMap = { + fontSize: "rem", + marginTop: "%", + }; + expect(applyCssUnits("fontSize", 2, unitMap)).toBe("2rem"); + expect(applyCssUnits("marginTop", 5, unitMap)).toBe("5%"); + }); + + it("falls back to default px if unit not found in map", () => { + expect(applyCssUnits("paddingLeft", 8, {})).toBe("8px"); + }); + + it("omits unit for known unitless properties", () => { + // @emotion/unitless is used to define unitless properties + expect(applyCssUnits("lineHeight", 1.2)).toBe("1.2"); + expect(applyCssUnits("zIndex", 2)).toBe("2"); + expect(applyCssUnits("flex", 1)).toBe("1"); + }); + + it("throws if value is not string or number", () => { + // @ts-expect-error - testing invalid input + expect(() => applyCssUnits("fontSize", null)).toThrowError(); + // @ts-expect-error - testing invalid input + expect(() => applyCssUnits("fontSize", {})).toThrowError(); + }); +}); diff --git a/src/__tests__/camelToKebab.test.ts b/src/__tests__/camelToKebab.test.ts new file mode 100644 index 0000000..3cb4ce7 --- /dev/null +++ b/src/__tests__/camelToKebab.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { camelToKebab } from "../helpers"; + +describe("camelToKebab", () => { + it("converts camelCase to kebab-case", () => { + expect(camelToKebab("backgroundColor")).toBe("background-color"); + expect(camelToKebab("fontSize")).toBe("font-size"); + expect(camelToKebab("borderTopLeftRadius")).toBe("border-top-left-radius"); + expect(camelToKebab("WebkitBorderBeforeWidth")).toBe( + "-webkit-border-before-width" + ); + }); + + it("returns the same string if there are no uppercase letters", () => { + expect(camelToKebab("color")).toBe("color"); + expect(camelToKebab("display")).toBe("display"); + }); + + it("handles empty string", () => { + expect(camelToKebab("")).toBe(""); + }); + + it("handles single uppercase character", () => { + expect(camelToKebab("A")).toBe("-a"); + }); +}); diff --git a/src/__tests__/stringifyCSSProperties.test.ts b/src/__tests__/stringifyCSSProperties.test.ts index 5e1516f..507654f 100644 --- a/src/__tests__/stringifyCSSProperties.test.ts +++ b/src/__tests__/stringifyCSSProperties.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { stringifyCSSProperties } from "../stringifyCSSProperties"; +import { stringifyCSSProperties } from "../stringify-react-styles"; describe("stringifyCSSProperties", () => { it("returns string", () => { @@ -14,14 +14,14 @@ describe("stringifyCSSProperties", () => { it("throws error for string input", () => { //@ts-ignore expect(() => stringifyCSSProperties("")).toThrowError( - "Invalid input: 'cssProperties' must be an object." + "[stringifyCSSProperties]: Expected 'cssProperties' to be a non-null object, but received (type:string)." ); }); it("throws error for 'null' input", () => { //@ts-ignore expect(() => stringifyCSSProperties(null)).toThrowError( - "Invalid input: 'cssProperties' must be an object." + "[stringifyCSSProperties]: Expected 'cssProperties' to be a non-null object, but received null (type:object)." ); }); @@ -99,3 +99,47 @@ describe("stringifyCSSProperties", () => { expect(actual).toBe(expected); }); }); + +describe("stringifyStyleMap accepts 'options' object", () => { + it("applies !important when the flag is set", () => { + expect( + stringifyCSSProperties( + { + display: "flex", + top: 100, + }, + { + important: true, + } + ) + ).toBe("display:flex!important;top:100px!important;"); + }); + + it("uses a global unit string when specified", () => { + expect( + stringifyCSSProperties( + { + top: 100, + }, + { + unit: "rem", + } + ) + ).toBe("top:100rem;"); + }); + + it("uses per-property unit map when provided (with 'px' fallback)", () => { + expect( + stringifyCSSProperties( + { + paddingBlock: 20, + paddingInline: 30, + top: 100, + }, + { + unit: { paddingBlock: "vh", paddingInline: "vw" }, + } + ) + ).toBe("padding-block:20vh;padding-inline:30vw;top:100px;"); + }); +}); diff --git a/src/__tests__/stringifyStyleDeclaration.test.ts b/src/__tests__/stringifyStyleDeclaration.test.ts new file mode 100644 index 0000000..45d80f4 --- /dev/null +++ b/src/__tests__/stringifyStyleDeclaration.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; + +import { stringifyStyleDeclaration } from "../stringifyStyleDeclaration"; + +describe("stringifyStyleDeclaration", () => { + it("converts a basic style declaration to a CSS string", () => { + expect( + stringifyStyleDeclaration({ + display: "flex", + fontSize: 16, + }) + ).toBe("display:flex;font-size:16px;"); + }); + + it("applies !important when the flag is set", () => { + expect( + stringifyStyleDeclaration( + { color: "red", marginTop: 8 }, + { important: true } + ) + ).toBe("color:red!important;margin-top:8px!important;"); + }); + + it("uses a global unit string when specified", () => { + expect(stringifyStyleDeclaration({ padding: 10 }, { unit: "rem" })).toBe( + "padding:10rem;" + ); + }); + + it("uses per-property unit map when provided (with 'px' fallback)", () => { + expect( + stringifyStyleDeclaration( + { fontSize: 2, marginLeft: 5, marginBottom: 10 }, + { unit: { fontSize: "em", marginLeft: "%" } } + ) + ).toBe("font-size:2em;margin-left:5%;margin-bottom:10px;"); + }); + + it("uses px as default unit if none is specified", () => { + expect(stringifyStyleDeclaration({ top: 100 })).toBe("top:100px;"); + }); + + it("omits units for unitless properties", () => { + expect(stringifyStyleDeclaration({ lineHeight: 1.5 })).toBe( + "line-height:1.5;" + ); + }); + + it("filters out invalid property values (e.g., null, undefined)", () => { + expect( + stringifyStyleDeclaration({ + margin: { top: 10 }, + fontSize: 14, + color: undefined, + background: null, + }) + ).toBe("font-size:14px;"); + }); + + it("throws an error if styleDeclaration is not an object", () => { + // @ts-expect-error - invalid input + expect(() => stringifyStyleDeclaration(null)).toThrowError(); + // @ts-expect-error - invalid input + expect(() => stringifyStyleDeclaration(123)).toThrowError(); + }); + + it("returns an empty string for an empty object", () => { + expect(stringifyStyleDeclaration({})).toBe(""); + }); +}); diff --git a/src/__tests__/stringifyStyleMap.test.ts b/src/__tests__/stringifyStyleMap.test.ts index 1f87ba4..42a6e69 100644 --- a/src/__tests__/stringifyStyleMap.test.ts +++ b/src/__tests__/stringifyStyleMap.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { stringifyStyleMap } from "../stringifyStyleMap"; +import { stringifyStyleMap } from "../stringify-react-styles"; describe("stringifyStyleMap", () => { const cssProperties = { color: "teal" }; @@ -16,14 +16,14 @@ describe("stringifyStyleMap", () => { it("throws error for string input", () => { //@ts-ignore expect(() => stringifyStyleMap("")).toThrowError( - "Invalid input: 'styleMap' must be an object." + "[stringifyStyleMap]: Expected 'styleMap' to be a non-null object, but received (type:string)." ); }); it("throws error for 'null' input", () => { //@ts-ignore expect(() => stringifyStyleMap(null)).toThrowError( - "Invalid input: 'styleMap' must be an object." + "[stringifyStyleMap]: Expected 'styleMap' to be a non-null object, but received null (type:object)." ); }); @@ -78,3 +78,65 @@ describe("stringifyStyleMap", () => { expect(actual).toBe(expected); }); }); + +describe("stringifyStyleMap accepts 'options' object", () => { + it("applies !important when the flag is set", () => { + expect( + stringifyStyleMap( + { + ".class-1": { + display: "flex", + }, + ".class-2": { + display: "flex", + }, + }, + { + important: true, + } + ) + ).toBe( + ".class-1{display:flex!important;}.class-2{display:flex!important;}" + ); + }); + + it("uses a global unit string when specified", () => { + expect( + stringifyStyleMap( + { + ".class-1": { + margin: 10, + }, + ".class-2": { + padding: 20, + }, + }, + { + unit: "rem", + } + ) + ).toBe(".class-1{margin:10rem;}.class-2{padding:20rem;}"); + }); + + it("uses per-property unit map when provided (with 'px' fallback)", () => { + expect( + stringifyStyleMap( + { + ".class-1": { + marginBlock: 30, + }, + ".class-2": { + top: 100, + paddingBlock: 20, + paddingInline: 10, + }, + }, + { + unit: { paddingBlock: "vh", paddingInline: "vw" }, + } + ) + ).toBe( + ".class-1{margin-block:30px;}.class-2{top:100px;padding-block:20vh;padding-inline:10vw;}" + ); + }); +}); diff --git a/src/__tests__/stringifyStyleRule.test.ts b/src/__tests__/stringifyStyleRule.test.ts new file mode 100644 index 0000000..a645ad5 --- /dev/null +++ b/src/__tests__/stringifyStyleRule.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { stringifyStyleRule } from "../stringifyStyleRule"; + +type MockStyleType = { + display?: string | number; + margin?: string | number; + padding?: string | number; + marginTop?: string | number; + paddingBottom?: string | number; + fontSize?: string | number; +}; + +describe("stringifyStyleRule", () => { + it("converts a simple style rule into a CSS string", () => { + const result = stringifyStyleRule({ + ".container": { + display: "flex", + margin: "10px 20px", + padding: 10, + }, + }); + + expect(result).toBe( + ".container{display:flex;margin:10px 20px;padding:10px;}" + ); + }); + + it("adds !important when options.important is true", () => { + const result = stringifyStyleRule( + { + ".container": { + margin: 0, + padding: 10, + }, + ".content": { + fontSize: 16, + }, + }, + { + important: true, + } + ); + + expect(result).toBe( + ".container{margin:0!important;padding:10px!important;}.content{font-size:16px!important;}" + ); + }); + + it("applies custom units from unit map", () => { + const result = stringifyStyleRule( + { + ".content": { + marginTop: 10, + paddingBottom: 5, + }, + ".text": { + padding: 5, + fontSize: 16, + }, + }, + { + unit: { + marginTop: "vh", + paddingBottom: "%", + fontSize: "em", + }, + } + ); + + expect(result).toBe( + ".content{margin-top:10vh;padding-bottom:5%;}.text{padding:5px;font-size:16em;}" + ); + }); + + it("skips rules with empty declarations", () => { + const result = stringifyStyleRule({ + ".empty": {}, + ".valid": { + display: "block", + }, + }); + + expect(result).toBe(".valid{display:block;}"); + }); + + it("returns an empty string for fully empty input", () => { + const result = stringifyStyleRule({}); + expect(result).toBe(""); + }); + + it("trims and normalizes selector spacing", () => { + const result = stringifyStyleRule({ + " .main > .child ": { + padding: 8, + }, + }); + + expect(result).toBe(".main>.child{padding:8px;}"); + }); + + it("throws a TypeError for non-object input", () => { + expect(() => + // @ts-expect-error + stringifyStyleRule("invalid") + ).toThrowError( + "[stringifyStyleRule]: Expected 'styleRule' to be a non-null object, but received invalid (type:string)." + ); + }); + + it("throws a TypeError for null input", () => { + expect(() => + // @ts-expect-error + stringifyStyleRule(null) + ).toThrowError( + "[stringifyStyleRule]: Expected 'styleRule' to be a non-null object, but received null (type:object)." + ); + }); +}); diff --git a/src/__tests__/trimCssSelector.test.ts b/src/__tests__/trimCssSelector.test.ts new file mode 100644 index 0000000..f248d2d --- /dev/null +++ b/src/__tests__/trimCssSelector.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { trimCssSelector } from "../helpers"; + +describe("trimCssSelector", () => { + it("removes spaces around combinators (+, ~, >)", () => { + expect(trimCssSelector("div > span")).toBe("div>span"); + expect(trimCssSelector("ul + li")).toBe("ul+li"); + expect(trimCssSelector("a ~ p")).toBe("a~p"); + }); + + it("collapses multiple spaces into a single space", () => { + expect(trimCssSelector("div span")).toBe("div span"); + expect(trimCssSelector(" .class #id ")).toBe(".class #id"); + }); + + it("trims leading and trailing whitespace", () => { + expect(trimCssSelector(" body > div ")).toBe("body>div"); + expect(trimCssSelector("\t\nsection > p\n")).toBe("section>p"); + }); + + it("handles mixed cases", () => { + expect(trimCssSelector(" div > span + a ~ p ")).toBe("div>span+a~p"); + }); + + it("returns empty string for empty input", () => { + expect(trimCssSelector("")).toBe(""); + }); + + it("does not modify valid compact selectors", () => { + expect(trimCssSelector("a>span+b~div")).toBe("a>span+b~div"); + }); +}); diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..ff89058 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,41 @@ +import unitless from "@emotion/unitless"; +import { CSSUnit, CSSUnitMap } from "./types"; + +const DEFAULT_UNIT = "px"; + +export const isCSSPropertyValue = (value: unknown): value is number | string => + typeof value === "number" || typeof value === "string"; + +export function camelToKebab(str: string) { + return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); +} + +export function trimCssSelector(selector: string) { + return selector + .replace(/\s*([+~>])\s*/g, "$1") + .replace(/\s{2,}/g, " ") + .trim(); +} + +export function applyCssUnits( + property: T, + value: string | number, + unit: CSSUnit | CSSUnitMap = DEFAULT_UNIT +) { + if (typeof value !== "string" && typeof value !== "number") { + throw new Error( + "Invalid input: value of 'cssProperties' must be string or number." + ); + } + + const isUnitless = unitless[property] === 1; + + if (typeof value === "string" || value === 0 || isUnitless) { + return `${value}`; + } + + const resolvedUnit = + (typeof unit === "string" ? unit : unit[property]) || DEFAULT_UNIT; + + return `${value}${resolvedUnit}`; +} diff --git a/src/index.ts b/src/index.ts index 4932b00..49ff973 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -export * from "./stringifyCSSProperties"; -export * from "./stringifyStyleMap"; +export * from "./stringify-react-styles"; +export * from "./stringifyStyleDeclaration"; +export * from "./stringifyStyleRule"; export * from "./types"; diff --git a/src/stringify-react-styles.ts b/src/stringify-react-styles.ts new file mode 100644 index 0000000..6fb6418 --- /dev/null +++ b/src/stringify-react-styles.ts @@ -0,0 +1,74 @@ +import { type CSSProperties } from "react"; +import { type StringifyOptions } from "./types"; +import { stringifyStyleDeclaration } from "./stringifyStyleDeclaration"; +import { stringifyStyleRule } from "./stringifyStyleRule"; + +/** + * Converts a CSSProperties object into a CSS string. + * + * @param {CSSProperties} cssProperties - An object representing CSS declarations, where keys are camelCased property names and values are the corresponding CSS values. + * - CSSProperties type comes from `@types/react` and is commonly used for inline styles in React components. + * + * @param {StringifyOptions | boolean} [optionsOrImportant=false] - Either a boolean indicating whether to append `!important` to each property, + * or an object with more detailed formatting options: + * - `important` (boolean): If true, appends `!important` to each property. + * - `unit` (object | string): A unit (like `'em'`, `'%'`, etc.) to apply to numeric values, or a map of per-property units. + * If a unit is not provided, `'px'` is used by default for numeric values (except for unitless properties). + * + * @returns {string} A formatted CSS string where each property is converted to kebab-case, units are added where necessary, + * and `!important` is appended if specified. + * + * @throws {TypeError} Throws if `cssProperties` is not a non-null object. + */ +export function stringifyCSSProperties( + cssProperties: CSSProperties, + optionsOrImportant: StringifyOptions | boolean = false +): string { + if (typeof cssProperties !== "object" || cssProperties === null) { + throw new TypeError( + `[stringifyCSSProperties]: Expected 'cssProperties' to be a non-null object, but received ${cssProperties} (type:${typeof cssProperties}).` + ); + } + + const options = + typeof optionsOrImportant === "boolean" + ? { + important: optionsOrImportant, + } + : optionsOrImportant; + + return stringifyStyleDeclaration(cssProperties, options); +} + +export type StyleMap = Record; + +/** + * Converts a `StyleMap` (a map of CSS selectors to React `CSSProperties`) into a string of CSS rules. + * + * @param {StyleMap} styleMap - An object where keys are CSS selectors and values are React-style `CSSProperties` objects (camelCased CSS declarations). + * @param {StringifyOptions | boolean} [optionsOrImportant=false] - Either a boolean flag to apply `!important` to all declarations, or an options object that may include `important` and per-property unit definitions. + * + * @returns {string} A formatted CSS string where each selector is followed by its corresponding declarations. + * Properties are converted to kebab-case and numeric values are suffixed with appropriate units unless the property is unitless. + * + * @throws {TypeError} Throws if `cssProperties` is not a non-null object. + */ +export function stringifyStyleMap( + styleMap: StyleMap, + optionsOrImportant: StringifyOptions | boolean = false +): string { + if (typeof styleMap !== "object" || styleMap === null) { + throw new TypeError( + `[stringifyStyleMap]: Expected 'styleMap' to be a non-null object, but received ${styleMap} (type:${typeof styleMap}).` + ); + } + + const options = + typeof optionsOrImportant === "boolean" + ? { + important: optionsOrImportant, + } + : optionsOrImportant; + + return stringifyStyleRule(styleMap, options); +} diff --git a/src/stringifyCSSProperties.ts b/src/stringifyCSSProperties.ts deleted file mode 100644 index a2b147b..0000000 --- a/src/stringifyCSSProperties.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type CSSProperties } from "react"; -import { applyCssUnits, camelToKebab, isCSSPropertyValue } from "./utils"; - -/** - * Converts a CSSProperties object into a CSS string. - * - * @param {CSSProperties} cssProperties - An object representing the CSS properties, where the keys are camelCased property names and the values are the corresponding CSS values. - * @param {boolean} [isImportant=false] - A flag indicating whether to append the `!important` statement to each CSS property. Defaults to `false`. - * - * @returns {string} - A formatted CSS string, with properties converted to kebab-case and units added where necessary. If `isImportant` is true, each property will be suffixed with `!important`. - */ -export function stringifyCSSProperties( - cssProperties: CSSProperties, - isImportant: boolean = false -) { - if (typeof cssProperties !== "object" || cssProperties === null) { - throw new Error("Invalid input: 'cssProperties' must be an object."); - } - - const important = isImportant ? "!important" : ""; - - return Object.entries(cssProperties) - .filter(([_, value]) => isCSSPropertyValue(value)) - .map( - ([key, value]) => - `${camelToKebab(key)}:${applyCssUnits(key, value)}${important};` - ) - .join(""); -} diff --git a/src/stringifyStyleDeclaration.ts b/src/stringifyStyleDeclaration.ts new file mode 100644 index 0000000..cafdf61 --- /dev/null +++ b/src/stringifyStyleDeclaration.ts @@ -0,0 +1,48 @@ +import { StringifyOptions } from "./types"; +import { applyCssUnits, camelToKebab, isCSSPropertyValue } from "./helpers"; + +export type StyleDeclaration = Record; + +/** + * Converts a StyleDeclaration object into a CSS string. + * + * @param {object} styleDeclaration - An object representing a CSS declaration block, where keys are camelCased CSS property names and values are the corresponding CSS values (strings or numbers). + * @param {StringifyOptions} [options] - Optional configuration object: + * - `important` — If set to `true`, appends `!important` to each CSS declaration. + * - `unit` — A CSS unit or a map of property keys to units: + * - If a string is provided (e.g. `'em'`), it will be used as the unit for all numeric properties. + * - If a map is provided (e.g. `{ width: 'rem' }`), the unit will be applied per numeric property. + * - If a property has a numeric value and no specific unit is defined in the map, `'px'` will be used by default. + * + * @returns {string} A formatted CSS string where: + * - Keys are converted from camelCase to kebab-case. + * - Units are added to numeric values as specified, or `'px'` by default. + * - Each declaration ends with a semicolon. + * - `!important` is appended if the `important` flag is set. + * + * @throws {TypeError} Throws if the `styleDeclaration` argument is not a non-null object. + */ +export function stringifyStyleDeclaration( + styleDeclaration: T, + options?: StringifyOptions +): string { + if (typeof styleDeclaration !== "object" || styleDeclaration === null) { + throw new TypeError( + `[stringifyStyleDeclaration]: Expected 'styleDeclaration' to be a non-null object, but received ${styleDeclaration} (type:${typeof styleDeclaration}).` + ); + } + + const importantSuffix = options?.important ? "!important" : ""; + + return Object.entries(styleDeclaration) + .filter(([_, value]) => isCSSPropertyValue(value)) + .map( + ([property, value]) => + `${camelToKebab(property)}:${applyCssUnits( + property, + value, + options?.unit + )}${importantSuffix};` + ) + .join(""); +} diff --git a/src/stringifyStyleMap.ts b/src/stringifyStyleMap.ts deleted file mode 100644 index 452967a..0000000 --- a/src/stringifyStyleMap.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { type StyleMap } from "./types"; -import { stringifyCSSProperties } from "./stringifyCSSProperties"; -import { trimCssSelector } from "./utils"; - -/** - * Converts a `StyleMap` (a map of CSS selectors to `CSSProperties`) into a string of CSS rules. - * - * @param {StyleMap} styleMap - An object where keys are CSS selectors and values are `CSSProperties` objects. - * @param {boolean} [isImportant=false] - A flag indicating whether to append the `!important` statement to each CSS property within the style map. Defaults to `false`. - * - * @returns {string} - A formatted string of CSS rules, where each selector's styles are converted to a CSS string. If `isImportant` is true, each property will be suffixed with `!important`. - */ -export function stringifyStyleMap( - styleMap: StyleMap, - isImportant: boolean = false -) { - if (typeof styleMap !== "object" || styleMap === null) { - throw new Error("Invalid input: 'styleMap' must be an object."); - } - - return Object.entries(styleMap) - .reduce((result, [key, value]) => { - if (Object.keys(value).length > 0) { - result.push( - `${trimCssSelector(key)}{${stringifyCSSProperties( - value, - isImportant - )}}` - ); - } - return result; - }, []) - .join(""); -} diff --git a/src/stringifyStyleRule.ts b/src/stringifyStyleRule.ts new file mode 100644 index 0000000..649e28f --- /dev/null +++ b/src/stringifyStyleRule.ts @@ -0,0 +1,49 @@ +import { + stringifyStyleDeclaration, + type StyleDeclaration, +} from "./stringifyStyleDeclaration"; +import { trimCssSelector } from "./helpers"; +import { type StringifyOptions } from "./types"; + +export type StyleRule = Record; + +/** + * Converts a style rule object into a CSS string. + * + * @template T - The type representing CSS property keys and values used in the style declarations. + * + * @param {StyleRule} styleRule - An object where keys are CSS selectors and values are style declarations + * (objects mapping CSS properties to values). + * @param {StringifyOptions} [options] - Optional settings controlling how declarations are stringified, + * such as appending `!important` or units. + * + * + * @returns {string} A CSS string where each selector and its corresponding declarations are formatted properly. + * Empty declarations are skipped. + * + * @throws {TypeError} Throws if the `styleRule` argument is not a non-null object. + */ +export function stringifyStyleRule( + styleRule: StyleRule, + options?: StringifyOptions +): string { + if (typeof styleRule !== "object" || styleRule === null) { + throw new TypeError( + `[stringifyStyleRule]: Expected 'styleRule' to be a non-null object, but received ${styleRule} (type:${typeof styleRule}).` + ); + } + + return Object.entries(styleRule) + .reduce((result, [selector, declaration]) => { + if (Object.keys(declaration).length > 0) { + result.push( + `${trimCssSelector(selector)}{${stringifyStyleDeclaration( + declaration, + options + )}}` + ); + } + return result; + }, []) + .join(""); +} diff --git a/src/types.ts b/src/types.ts index 6a2697c..379eeac 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,13 @@ -import { type CSSProperties } from "react"; +// TODO: Use https://github.com/w3c/webref to define css units ?! +export type CSSUnit = "px" | "em" | "rem" | "vw" | "vh" | "%"; -export type StyleMap = Record; +export type CSSUnitMap = { + [P in K]?: CSSUnit; +}; + +export type StringifyOptions< + T extends object = Record +> = { + important?: boolean; + unit?: CSSUnit | CSSUnitMap; +}; diff --git a/src/utils/applyCssUnits.ts b/src/utils/applyCssUnits.ts deleted file mode 100644 index e124f9d..0000000 --- a/src/utils/applyCssUnits.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { isUnitless } from "./isUnitless"; - -export function applyCssUnits( - prop: string, - value: string | number, - units: string = "px" -) { - if (typeof value !== "string" && typeof value !== "number") { - throw new Error( - "Invalid input: value of 'cssProperties' must be string or number." - ); - } - - if (typeof value === "number" && value !== 0 && !isUnitless(prop)) { - return `${value}${units}`; - } - - return `${value}`; -} diff --git a/src/utils/camelToKebab.ts b/src/utils/camelToKebab.ts deleted file mode 100644 index f84b75d..0000000 --- a/src/utils/camelToKebab.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function camelToKebab(str: string) { - return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); -} diff --git a/src/utils/index.ts b/src/utils/index.ts deleted file mode 100644 index cd678c8..0000000 --- a/src/utils/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./camelToKebab"; -export * from "./isUnitless"; -export * from "./applyCssUnits"; -export * from "./trimCssSelector"; -export * from "./isCSSPropertyValue"; diff --git a/src/utils/isCSSPropertyValue.ts b/src/utils/isCSSPropertyValue.ts deleted file mode 100644 index 8f1a057..0000000 --- a/src/utils/isCSSPropertyValue.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const isCSSPropertyValue = (value: unknown): value is number | string => - typeof value === "number" || typeof value === "string"; diff --git a/src/utils/isUnitless.ts b/src/utils/isUnitless.ts deleted file mode 100644 index bb18cde..0000000 --- a/src/utils/isUnitless.ts +++ /dev/null @@ -1,5 +0,0 @@ -import unitless from "@emotion/unitless"; - -export function isUnitless(prop: string) { - return unitless[prop] === 1; -} diff --git a/src/utils/trimCssSelector.ts b/src/utils/trimCssSelector.ts deleted file mode 100644 index 9a203e4..0000000 --- a/src/utils/trimCssSelector.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function trimCssSelector(selector: string) { - return selector - .replace(/\s*([+~>])\s*/g, "$1") - .replace(/\s{2,}/g, " ") - .trim(); -}