Skip to content

Commit 20261cb

Browse files
seefeldbclaude
andauthored
feat(html): add comprehensive React-style object-as-styles support (#1923)
* feat(html): add comprehensive React-style object-as-styles support Add full support for passing style objects in JSX, matching React's style prop behavior. This enables more ergonomic styling in patterns and recipes. ## Key Features - **CamelCase to kebab-case conversion**: Automatically converts React-style property names (e.g., `backgroundColor`) to CSS property names (e.g., `background-color`) - **Smart unit handling**: Automatically appends 'px' to numeric values for properties that require units, while respecting unitless properties like `opacity`, `zIndex`, `flexGrow`, `lineHeight`, etc. - **Vendor prefix support**: Handles browser vendor prefixes correctly (e.g., `WebkitTransform` → `-webkit-transform`, `MozAppearance` → `-moz-appearance`) - **Null/undefined value handling**: Gracefully skips null and undefined values, allowing conditional styling - **Complex value support**: Handles complex CSS values like gradients, shadows, and transforms without modification ## Implementation - Added `styleObjectToCssString()` helper function in both render.ts and utils.ts for consistent conversion logic - Updated `setProp()` in render.ts to detect style objects and convert them to CSS strings before setting the attribute - Updated mock document's `setProp()` in utils.ts to ensure tests work correctly with style objects ## Test Coverage Added 8 comprehensive test cases covering: - Basic React-style object to CSS string conversion - Unitless numeric properties - Vendor prefixes (webkit, moz, ms, o) - Zero value handling - Null and undefined value handling - Complex CSS values (box-shadow, gradients, transforms) - Style objects alongside other props - Empty style objects All tests pass with full type safety. ## Usage Example ```tsx <div style={{ backgroundColor: "red", fontSize: 16, // → "16px" opacity: 0.5, // → "0.5" (unitless) WebkitTransform: "rotate(45deg)", // → "-webkit-transform" margin: 0, // → "0" boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)" }} /> ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(html): add flex to unitless CSS properties Add the `flex` property to the unitless properties set to prevent invalid CSS generation. Without this fix, numeric flex values like `flex: 1` were incorrectly converted to `flex: 1px`, which breaks flexbox layouts. ## Changes - Added "flex" to unitlessProperties Set in both render.ts and utils.ts - Updated test to verify flex property remains unitless ## Test Coverage Updated "handles unitless numeric properties" test to include: - `flex: 1` → "flex: 1" (not "flex: 1px") Fixes issue where flexbox layouts using numeric flex values would render invalid CSS. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(html): preserve CSS custom properties without px suffix Prevent automatic 'px' suffix addition for CSS custom properties (CSS variables). Custom properties can represent any type of value and should never be modified by auto-unit logic. ## The Problem CSS custom properties starting with '--' were incorrectly receiving 'px' suffixes when given numeric values, causing: - `{ "--scale": 2 }` → `--scale: 2px` (invalid, should be `--scale: 2`) - `{ "--opacity": 0.5 }` → `--opacity: 0.5px` (invalid, should be `--opacity: 0.5`) This diverges from React's behavior and breaks CSS variable usage. ## The Solution Added check for `cssKey.startsWith("--")` before applying 'px' suffix in both render.ts and utils.ts. CSS custom properties now preserve their values exactly as provided. ## Test Coverage Added comprehensive test "handles CSS custom properties (variables) without adding px" covering: - Numeric variables: `--scale: 2` (not `2px`) - Decimal variables: `--opacity: 0.5` (not `0.5px`) - Integer variables: `--columns: 3` (not `3px`) - String variables: `--primary-color: #ff0000` (unchanged) All tests pass (3 passed, 39 steps). ## Usage Example ```tsx <div style={{ "--scale": 2, // → "--scale: 2" "--opacity": 0.5, // → "--opacity: 0.5" "--primary-color": "red" // → "--primary-color: red" }} /> ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix(html): preserve case sensitivity for CSS custom properties CSS custom properties (CSS variables) are case-sensitive and must retain their original casing. Previously, all CSS property names were lowercased, breaking case-sensitive custom properties. ## The Problem CSS custom properties were being lowercased during conversion: - `{ "--MyAccent": "blue" }` → `--myaccent: blue` (incorrect) - `{ "--THEME-Color": "red" }` → `--theme-color: red` (incorrect) This breaks CSS variable usage since `--MyAccent` and `--myaccent` are distinct variables in CSS. ## The Solution Skip camelCase-to-kebab-case conversion and lowercasing for properties starting with `--`. CSS custom properties now preserve their exact casing as provided. Updated logic in both render.ts and utils.ts: - Check `!key.startsWith("--")` before applying transformations - Only convert standard CSS properties to kebab-case and lowercase - Preserve custom properties exactly as written ## Test Coverage Updated "handles CSS custom properties (variables) without adding px" test to verify case preservation: - `--MyAccent: blue` (preserves mixed case) - `--THEME-Color: green` (preserves uppercase and mixed case) - `--scale: 2` (preserves lowercase) All tests pass (3 passed, 39 steps). ## Usage Example ```tsx <div style={{ "--MyAccent": "blue", // → "--MyAccent: blue" (case preserved) "--THEME-Color": "red", // → "--THEME-Color: red" (case preserved) backgroundColor: "white" // → "background-color: white" (converted) }} /> ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * dedupe styleObjectToCssString function * move utils.ts to test/ since it's only a test helper * Revert "move utils.ts to test/ since it's only a test helper" This reverts commit 5714458. * test/utils.ts was used elsewhere for non-tests, so renaming to mock-doc instead --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29e9b3f commit 20261cb

File tree

6 files changed

+417
-5
lines changed

6 files changed

+417
-5
lines changed

packages/cli/lib/charm-render.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { loadManager } from "./charm.ts";
44
import { CharmsController } from "@commontools/charm/ops";
55
import type { CharmConfig } from "./charm.ts";
66
import { getLogger } from "@commontools/utils/logger";
7-
import { MockDoc } from "@commontools/html/utils";
7+
import { MockDoc } from "../../html/src/mock-doc.ts";
88

99
const logger = getLogger("charm-render", { level: "info", enabled: false });
1010

packages/html/src/utils.ts renamed to packages/html/src/mock-doc.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import * as htmlparser2 from "htmlparser2";
55
import * as domhandler from "domhandler";
66
import * as domserializer from "dom-serializer";
7-
import { RenderOptions } from "./render.ts";
7+
import { RenderOptions, styleObjectToCssString } from "./render.ts";
88

99
function renderOptionsFromDoc(document: globalThis.Document): RenderOptions {
1010
return {
@@ -16,6 +16,19 @@ function renderOptionsFromDoc(document: globalThis.Document): RenderOptions {
1616
) {
1717
const el = element as any;
1818

19+
// Handle style object specially - convert to CSS string
20+
if (
21+
key === "style" &&
22+
el.attribs &&
23+
typeof value === "object" &&
24+
value !== null &&
25+
!Array.isArray(value)
26+
) {
27+
const cssString = styleObjectToCssString(value as Record<string, any>);
28+
el.attribs["style"] = cssString;
29+
return;
30+
}
31+
1932
// Handle data-* attributes specially - they need to be set as HTML attributes
2033
// to populate the dataset property correctly
2134
if (key.startsWith("data-") && el.attribs) {

packages/html/src/render.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,88 @@ const listen = (
321321
};
322322
};
323323

324+
/**
325+
* Converts a React-style CSS object to a CSS string.
326+
* Supports vendor prefixes, pixel value shorthand, and comprehensive CSS properties.
327+
* @param styleObject - The style object with React-style camelCase properties
328+
* @returns A CSS string suitable for the style attribute
329+
*/
330+
export const styleObjectToCssString = (
331+
styleObject: Record<string, any>,
332+
): string => {
333+
return Object.entries(styleObject)
334+
.map(([key, value]) => {
335+
// Skip if value is null or undefined
336+
if (value == null) return "";
337+
338+
// Convert camelCase to kebab-case, handling vendor prefixes
339+
let cssKey = key;
340+
341+
// CSS custom properties (--*) are case-sensitive and should not be transformed
342+
if (!key.startsWith("--")) {
343+
// Handle vendor prefixes (WebkitTransform -> -webkit-transform)
344+
if (/^(webkit|moz|ms|o)[A-Z]/.test(key)) {
345+
cssKey = "-" + key;
346+
}
347+
348+
// Convert camelCase to kebab-case
349+
cssKey = cssKey.replace(/([A-Z])/g, "-$1").toLowerCase();
350+
}
351+
352+
// Convert value to string
353+
let cssValue = value;
354+
355+
// Add 'px' suffix to numeric values for properties that need it
356+
// Exceptions: properties that accept unitless numbers
357+
const unitlessProperties = new Set([
358+
"animation-iteration-count",
359+
"column-count",
360+
"fill-opacity",
361+
"flex",
362+
"flex-grow",
363+
"flex-shrink",
364+
"font-weight",
365+
"line-height",
366+
"opacity",
367+
"order",
368+
"orphans",
369+
"stroke-opacity",
370+
"widows",
371+
"z-index",
372+
"zoom",
373+
]);
374+
375+
if (
376+
typeof value === "number" &&
377+
!cssKey.startsWith("--") && // CSS custom properties should never get px
378+
!unitlessProperties.has(cssKey) &&
379+
value !== 0
380+
) {
381+
cssValue = `${value}px`;
382+
} else {
383+
cssValue = String(value);
384+
}
385+
386+
return `${cssKey}: ${cssValue}`;
387+
})
388+
.filter((s) => s !== "")
389+
.join("; ");
390+
};
391+
324392
const setProp = <T>(target: T, key: string, value: unknown) => {
393+
// Handle style object specially - convert to CSS string
394+
if (
395+
key === "style" &&
396+
target instanceof HTMLElement &&
397+
isRecord(value)
398+
) {
399+
const cssString = styleObjectToCssString(value);
400+
if (target.getAttribute("style") !== cssString) {
401+
target.setAttribute("style", cssString);
402+
}
403+
return;
404+
}
405+
325406
// Handle data-* attributes specially - they need to be set as HTML attributes
326407
// to populate the dataset property correctly
327408
if (key.startsWith("data-") && target instanceof Element) {

packages/html/test/html-recipes.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, beforeEach, describe, it } from "@std/testing/bdd";
22
import { render, VNode } from "../src/index.ts";
3-
import { MockDoc } from "../src/utils.ts";
3+
import { MockDoc } from "../src/mock-doc.ts";
44
import {
55
type Cell,
66
createBuilder,

packages/html/test/mockdoc.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it } from "@std/testing/bdd";
2-
import { MockDoc } from "../src/utils.ts";
2+
import { MockDoc } from "../src/mock-doc.ts";
33
import { assert } from "@std/assert";
44

55
describe("MockDoc", () => {

0 commit comments

Comments
 (0)