diff --git a/README.md b/README.md index c2219ca..abc955d 100644 --- a/README.md +++ b/README.md @@ -401,6 +401,38 @@ const row = Css.newMarker(); ``` +## Custom Selectors with `.css.ts` and `Css.className(...)` + +For selectors that do not fit naturally into a `Css.*.$` chain, i.e. descendant selectors, `:nth-child(...)`, or library-driven markup hooks, put that selector logic in a `.css.ts` file and then attach the exported class name through `Css.className(...)`. + +```ts +// DataGrid.css.ts +import { Css } from "./Css"; + +export const zebraRows = "zebraRows"; + +export const css = { + [`.${zebraRows} tbody tr:nth-child(even) td`]: Css.bgLightGray.$, + [`.${zebraRows} tbody tr:hover td`]: Css.bgBlue.white.$, +}; +``` + +```tsx +// DataGrid.tsx +import { Css } from "./Css"; +import { zebraRows } from "./DataGrid.css.ts"; + +export function DataGrid() { + return ( + + {/* rows */} +
+ ); +} +``` + +This keeps the base element styling in Truss, i.e. `Css.w100`, while using the `.css.ts` class as the anchor for arbitrary selectors. At build time, Truss merges both into the final `className` prop. + ## XStyles / Xss Extension Contracts Truss liberally borrows the idea of type-checked "extension" CSS from the currently-unreleased Facebook XStyles library (at least in theory; I've only seen one or two slides for this feature of XStyles, but I'm pretty sure Truss is faithful re-implementation of it). diff --git a/packages/app/src/Css.ts b/packages/app/src/Css.ts index 7a5381e..e9b4145 100644 --- a/packages/app/src/Css.ts +++ b/packages/app/src/Css.ts @@ -2412,6 +2412,12 @@ class CssBuilder { return this.add(omitUndefinedValues(props)); } + /** Marker for the build-time transform to append a raw className. */ + className(className: string): CssBuilder { + void className; + return this; + } + /** Convert a style hash into `{ className, style }` props for manual spreading into non-`css=` contexts. */ props(styles: Properties): Record { return trussProps(styles as any); diff --git a/packages/template-tachyons/src/Css.ts b/packages/template-tachyons/src/Css.ts index 968434f..110777e 100644 --- a/packages/template-tachyons/src/Css.ts +++ b/packages/template-tachyons/src/Css.ts @@ -2724,6 +2724,12 @@ class CssBuilder { return this.add(omitUndefinedValues(props)); } + /** Marker for the build-time transform to append a raw className. */ + className(className: string): CssBuilder { + void className; + return this; + } + /** Convert a style hash into `{ className, style }` props for manual spreading into non-`css=` contexts. */ props(styles: Properties): Record { return trussProps(styles as any); diff --git a/packages/testing-tachyons-rn/src/Css.ts b/packages/testing-tachyons-rn/src/Css.ts index 1f4325d..0e6c30f 100644 --- a/packages/testing-tachyons-rn/src/Css.ts +++ b/packages/testing-tachyons-rn/src/Css.ts @@ -646,6 +646,12 @@ class CssBuilder { const rules = { ...this.rules, [selector]: { ...(this.rules as any)[selector], ...newRules } }; return this.newCss({ rules: rules as any }); } + + /** Marker for the build-time transform to append a raw className. */ + className(className: string): CssBuilder { + void className; + return this; + } } /** Sort keys so equivalent rule objects have deterministic shape. */ diff --git a/packages/testing-tachyons/src/Css.ts b/packages/testing-tachyons/src/Css.ts index 4a7cf73..6b1d2dd 100644 --- a/packages/testing-tachyons/src/Css.ts +++ b/packages/testing-tachyons/src/Css.ts @@ -2410,6 +2410,12 @@ class CssBuilder { const rules = { ...this.rules, [selector]: { ...(this.rules as any)[selector], ...newRules } }; return this.newCss({ rules: rules as any }); } + + /** Marker for the build-time transform to append a raw className. */ + className(className: string): CssBuilder { + void className; + return this; + } } /** Sort keys so equivalent rule objects have deterministic shape. */ diff --git a/packages/truss/src/generate.ts b/packages/truss/src/generate.ts index 88cbb2c..436928e 100644 --- a/packages/truss/src/generate.ts +++ b/packages/truss/src/generate.ts @@ -232,6 +232,12 @@ class CssBuilder { return this.newCss({ rules: rules as any }); } + /** Marker for the build-time transform to append a raw className. */ + className(className: string): CssBuilder { + void className; + return this; + } + } /** Sort keys so equivalent rule objects have deterministic shape. */ @@ -542,6 +548,12 @@ class CssBuilder { return this.add(omitUndefinedValues(props)); } + /** Marker for the build-time transform to append a raw className. */ + className(className: string): CssBuilder { + void className; + return this; + } + /** Convert a style hash into \`{ className, style }\` props for manual spreading into non-\`css=\` contexts. */ props(styles: Properties): Record { return trussProps(styles as any); diff --git a/packages/truss/src/plugin/emit-truss.ts b/packages/truss/src/plugin/emit-truss.ts index 920dd70..6b046cc 100644 --- a/packages/truss/src/plugin/emit-truss.ts +++ b/packages/truss/src/plugin/emit-truss.ts @@ -265,7 +265,7 @@ export function collectAtomicRules(chains: ResolvedChain[], mapping: TrussMappin let needsMaybeInc = false; function collectSegment(seg: ResolvedSegment): void { - if (seg.error || seg.styleArrayArg) return; + if (seg.error || seg.styleArrayArg || seg.classNameArg) return; if (seg.typographyLookup) { for (const segments of Object.values(seg.typographyLookup.segmentsByName)) { for (const nestedSeg of segments) { @@ -577,7 +577,7 @@ export function buildStyleHashProperties( } for (const seg of segments) { - if (seg.error || seg.styleArrayArg || seg.typographyLookup) continue; + if (seg.error || seg.styleArrayArg || seg.typographyLookup || seg.classNameArg) continue; const { prefix } = segmentContext(seg, mapping); const isConditional = prefix !== ""; diff --git a/packages/truss/src/plugin/resolve-chain.ts b/packages/truss/src/plugin/resolve-chain.ts index 21b06a8..3e57327 100644 --- a/packages/truss/src/plugin/resolve-chain.ts +++ b/packages/truss/src/plugin/resolve-chain.ts @@ -319,6 +319,19 @@ export function resolveChain(chain: ChainNode[], mapping: TrussMapping): Resolve continue; } + // Raw class passthrough, i.e. `Css.className(buttonClass).df.$` + if (abbr === "className") { + const seg = resolveClassNameCall( + node, + currentMediaQuery, + currentPseudoClass, + currentPseudoElement, + currentWhenPseudo, + ); + segments.push(seg); + continue; + } + if (abbr === "typography") { const resolved = resolveTypographyCall( node, @@ -659,6 +672,37 @@ function buildParameterizedSegment(params: { return base; } +function resolveClassNameCall( + node: CallChainNode, + mediaQuery: string | null, + pseudoClass: string | null, + pseudoElement: string | null, + whenPseudo?: { pseudo: string; markerNode?: any; relationship?: string } | null, +): ResolvedSegment { + if (node.args.length !== 1) { + throw new UnsupportedPatternError(`className() expects exactly 1 argument, got ${node.args.length}`); + } + + const arg = node.args[0]; + if (arg.type === "SpreadElement") { + throw new UnsupportedPatternError(`className() does not support spread arguments`); + } + + if (mediaQuery || pseudoClass || pseudoElement || whenPseudo) { + // I.e. `ifSm.className("x")` cannot be represented as a runtime-only class append. + throw new UnsupportedPatternError( + `className() cannot be used inside media query, pseudo-class, pseudo-element, or when() contexts`, + ); + } + + return { + // I.e. this is metadata for the rewriter/runtime, not an atomic CSS rule. + abbr: "className", + defs: {}, + classNameArg: arg, + }; +} + /** * Resolve an `add(...)` or `addCss(...)` call. * diff --git a/packages/truss/src/plugin/rewrite-sites.ts b/packages/truss/src/plugin/rewrite-sites.ts index 602fbab..6cbaeb9 100644 --- a/packages/truss/src/plugin/rewrite-sites.ts +++ b/packages/truss/src/plugin/rewrite-sites.ts @@ -129,7 +129,8 @@ function buildStyleHashFromChain(chain: ResolvedChain, options: RewriteSitesOpti * Build ObjectExpression members from a list of segments. * * Normal segments are batched and processed by buildStyleHashProperties. - * Special segments (styleArrayArg, typographyLookup) produce SpreadElements. + * Special segments (styleArrayArg, typographyLookup, classNameArg) produce + * spread members or reserved metadata properties. */ function buildStyleHashMembers( segments: ResolvedSegment[], @@ -137,6 +138,7 @@ function buildStyleHashMembers( ): (t.ObjectProperty | t.SpreadElement)[] { const members: (t.ObjectProperty | t.SpreadElement)[] = []; const normalSegs: ResolvedSegment[] = []; + const classNameArgs: t.Expression[] = []; function flushNormal(): void { if (normalSegs.length > 0) { @@ -148,6 +150,12 @@ function buildStyleHashMembers( for (const seg of segments) { if (seg.error) continue; + if (seg.classNameArg) { + // I.e. `Css.className(cls).df.$` becomes `className: [cls]` in the style hash. + classNameArgs.push(t.cloneNode(seg.classNameArg, true) as t.Expression); + continue; + } + if (seg.styleArrayArg) { flushNormal(); if (seg.isAddCss && t.isObjectExpression(seg.styleArrayArg)) { @@ -177,6 +185,10 @@ function buildStyleHashMembers( } flushNormal(); + if (classNameArgs.length > 0) { + // I.e. keep raw class expressions separate from atomic CSS-property entries. + members.push(t.objectProperty(t.identifier("className"), t.arrayExpression(classNameArgs))); + } return members; } @@ -224,7 +236,7 @@ function buildAddCssObjectMembers(styleObject: t.ObjectExpression): (t.ObjectPro function collectConditionalOnlyProps(segments: ResolvedSegment[]): Set { const allProps = new Map(); for (const seg of segments) { - if (seg.error || seg.styleArrayArg || seg.typographyLookup) continue; + if (seg.error || seg.styleArrayArg || seg.typographyLookup || seg.classNameArg) continue; const hasCondition = !!(seg.pseudoClass || seg.mediaQuery || seg.pseudoElement || seg.whenPseudo); const props = seg.variableProps ?? Object.keys(seg.defs); for (const prop of props) { @@ -258,6 +270,10 @@ function mergeConditionalBranchMembers( const prop = propertyName(member.key); const prior = previousProperties.get(prop); + if (prop === "className" && prior) { + // I.e. `Css.className(base).if(cond).className(extra).$` should keep both classes when true. + return t.objectProperty(clonePropertyKey(member.key), mergeClassNameValues(prior.value as t.Expression, member.value as t.Expression)); + } if (!prior || !conditionalOnlyProps.has(prop)) { return member; } @@ -290,6 +306,19 @@ function mergePropertyValues(previousValue: t.Expression, currentValue: t.Expres return t.cloneNode(currentValue, true); } +function mergeClassNameValues(previousValue: t.Expression, currentValue: t.Expression): t.ArrayExpression { + return t.arrayExpression([...toClassNameElements(previousValue), ...toClassNameElements(currentValue)]); +} + +function toClassNameElements(value: t.Expression): t.Expression[] { + if (t.isArrayExpression(value)) { + return value.elements.flatMap((element) => { + return element && !t.isSpreadElement(element) ? [t.cloneNode(element, true)] : []; + }); + } + return [t.cloneNode(value, true)]; +} + function mergeTupleValue( tuple: t.ArrayExpression, classNames: string, @@ -375,6 +404,8 @@ function injectDebugInfo( return ( t.isObjectProperty(p) && !( + (t.isIdentifier(p.key) && p.key.name === "className") || + (t.isStringLiteral(p.key) && p.key.value === "className") || (t.isIdentifier(p.key) && p.key.name === "__marker") || (t.isStringLiteral(p.key) && p.key.value === "__marker") ) diff --git a/packages/truss/src/plugin/transform.test.ts b/packages/truss/src/plugin/transform.test.ts index d91148d..6b1b81a 100644 --- a/packages/truss/src/plugin/transform.test.ts +++ b/packages/truss/src/plugin/transform.test.ts @@ -1798,6 +1798,27 @@ describe("transform", () => { ); }); + test("custom className passthrough: Css.className(cls).df.$", () => { + expectTrussTransform( + ` + import { Css } from "./Css"; + const cls = getClass(); + const el =
; + `, + ).toHaveTrussOutput( + ` + import { trussProps } from "@homebound/truss/runtime"; + const cls = getClass(); + const el =
; + `, + ` + .df { + display: flex; + } + `, + ); + }); + test("className merging: css + variable className expression", () => { expectTrussTransform( ` diff --git a/packages/truss/src/plugin/types.ts b/packages/truss/src/plugin/types.ts index 449fe17..a34830d 100644 --- a/packages/truss/src/plugin/types.ts +++ b/packages/truss/src/plugin/types.ts @@ -56,6 +56,8 @@ export interface ResolvedSegment { styleArrayArg?: any; /** True when the composed style arg came from `addCss(...)`. */ isAddCss?: boolean; + /** For custom class names inserted via `className(...)`. */ + classNameArg?: any; /** The evaluated literal value of the argument, if it was a compile-time constant. */ argResolved?: string; /** For runtime typography lookups: the lookup metadata and runtime key node. */ diff --git a/packages/truss/src/runtime.test.ts b/packages/truss/src/runtime.test.ts index f50bad0..2a0c437 100644 --- a/packages/truss/src/runtime.test.ts +++ b/packages/truss/src/runtime.test.ts @@ -17,6 +17,11 @@ describe("trussProps", () => { expect(result).toEqual({ className: "black blue_h df" }); }); + test("passes through custom className entries", () => { + const result = trussProps({ className: ["custom", undefined, "custom-2"], display: "df" }); + expect(result).toEqual({ className: "custom custom-2 df" }); + }); + test("handles variable tuples with CSS variables", () => { const result = trussProps({ marginTop: ["mt_var", { "--marginTop": "16px" }] }); expect(result).toEqual({ diff --git a/packages/truss/src/runtime.ts b/packages/truss/src/runtime.ts index 8ef8cb6..7e75129 100644 --- a/packages/truss/src/runtime.ts +++ b/packages/truss/src/runtime.ts @@ -22,7 +22,8 @@ export type TrussStyleValue = | [classNames: string, vars: Record, debugInfo: TrussDebugInfo]; /** A property-keyed style hash where each key owns one logical CSS property. */ -export type TrussStyleHash = Record; +export type TrussCustomClassNameValue = string | ReadonlyArray; +export type TrussStyleHash = Record; const shouldValidateTrussStyleValues = resolveShouldValidateTrussStyleValues(); const TRUSS_CSS_CHUNKS = "__trussCssChunks__"; @@ -44,8 +45,6 @@ export function trussProps( const debugSources: string[] = []; for (const [key, value] of Object.entries(merged)) { - if (shouldValidateTrussStyleValues) assertValidTrussStyleValue(key, value); - // __marker is a special key — its value is a marker class name, not a CSS property if (key === "__marker") { if (typeof value === "string") { @@ -54,6 +53,14 @@ export function trussProps( continue; } + if (key === "className") { + // I.e. plugin-emitted raw class names that should flow straight into the final prop. + appendCustomClassNames(classNames, value); + continue; + } + + if (shouldValidateTrussStyleValues) assertValidTrussStyleValue(key, value); + if (typeof value === "string") { // I.e. "df" or "black blue_h" classNames.push(value); @@ -88,6 +95,22 @@ export function trussProps( return props; } +function appendCustomClassNames(classNames: string[], value: unknown): void { + if (typeof value === "string") { + // I.e. `className: "custom"` + classNames.push(value); + return; + } + + if (!Array.isArray(value)) return; + for (const entry of value) { + if (typeof entry === "string") { + // I.e. `className: ["custom", cond && "selected"]` + classNames.push(entry); + } + } +} + /** Merge explicit className/style with Truss style hashes. */ export function mergeProps( explicitClassName: string | undefined,