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 (
+
+ );
+}
+```
+
+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,