Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,38 @@ const row = Css.newMarker();
</tr>
```

## 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 (
<table css={Css.w100.className(zebraRows).$}>
<tbody>{/* rows */}</tbody>
</table>
);
}
```

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).
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/Css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,12 @@ class CssBuilder<T extends Properties> {
return this.add(omitUndefinedValues(props));
}

/** Marker for the build-time transform to append a raw className. */
className(className: string): CssBuilder<T> {
void className;
return this;
}

/** Convert a style hash into `{ className, style }` props for manual spreading into non-`css=` contexts. */
props(styles: Properties): Record<string, unknown> {
return trussProps(styles as any);
Expand Down
6 changes: 6 additions & 0 deletions packages/template-tachyons/src/Css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2724,6 +2724,12 @@ class CssBuilder<T extends Properties> {
return this.add(omitUndefinedValues(props));
}

/** Marker for the build-time transform to append a raw className. */
className(className: string): CssBuilder<T> {
void className;
return this;
}

/** Convert a style hash into `{ className, style }` props for manual spreading into non-`css=` contexts. */
props(styles: Properties): Record<string, unknown> {
return trussProps(styles as any);
Expand Down
6 changes: 6 additions & 0 deletions packages/testing-tachyons-rn/src/Css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,12 @@ class CssBuilder<T extends Properties> {
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<T> {
void className;
return this;
}
}

/** Sort keys so equivalent rule objects have deterministic shape. */
Expand Down
6 changes: 6 additions & 0 deletions packages/testing-tachyons/src/Css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2410,6 +2410,12 @@ class CssBuilder<T extends Properties> {
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<T> {
void className;
return this;
}
}

/** Sort keys so equivalent rule objects have deterministic shape. */
Expand Down
12 changes: 12 additions & 0 deletions packages/truss/src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,12 @@ class CssBuilder<T extends Properties> {
return this.newCss({ rules: rules as any });
}

/** Marker for the build-time transform to append a raw className. */
className(className: string): CssBuilder<T> {
void className;
return this;
}

}

/** Sort keys so equivalent rule objects have deterministic shape. */
Expand Down Expand Up @@ -542,6 +548,12 @@ class CssBuilder<T extends Properties> {
return this.add(omitUndefinedValues(props));
}

/** Marker for the build-time transform to append a raw className. */
className(className: string): CssBuilder<T> {
void className;
return this;
}

/** Convert a style hash into \`{ className, style }\` props for manual spreading into non-\`css=\` contexts. */
props(styles: Properties): Record<string, unknown> {
return trussProps(styles as any);
Expand Down
4 changes: 2 additions & 2 deletions packages/truss/src/plugin/emit-truss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 !== "";
Expand Down
44 changes: 44 additions & 0 deletions packages/truss/src/plugin/resolve-chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*
Expand Down
35 changes: 33 additions & 2 deletions packages/truss/src/plugin/rewrite-sites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,16 @@ 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[],
options: RewriteSitesOptions,
): (t.ObjectProperty | t.SpreadElement)[] {
const members: (t.ObjectProperty | t.SpreadElement)[] = [];
const normalSegs: ResolvedSegment[] = [];
const classNameArgs: t.Expression[] = [];

function flushNormal(): void {
if (normalSegs.length > 0) {
Expand All @@ -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)) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -224,7 +236,7 @@ function buildAddCssObjectMembers(styleObject: t.ObjectExpression): (t.ObjectPro
function collectConditionalOnlyProps(segments: ResolvedSegment[]): Set<string> {
const allProps = new Map<string, boolean>();
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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
)
Expand Down
21 changes: 21 additions & 0 deletions packages/truss/src/plugin/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1798,6 +1798,27 @@ describe("transform", () => {
);
});

test("custom className passthrough: Css.className(cls).df.$", () => {
expectTrussTransform(
`
import { Css } from "./Css";
const cls = getClass();
const el = <div css={Css.className(cls).df.$} />;
`,
).toHaveTrussOutput(
`
import { trussProps } from "@homebound/truss/runtime";
const cls = getClass();
const el = <div {...trussProps({ display: "df", className: [cls] })} />;
`,
`
.df {
display: flex;
}
`,
);
});

test("className merging: css + variable className expression", () => {
expectTrussTransform(
`
Expand Down
2 changes: 2 additions & 0 deletions packages/truss/src/plugin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
5 changes: 5 additions & 0 deletions packages/truss/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
29 changes: 26 additions & 3 deletions packages/truss/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export type TrussStyleValue =
| [classNames: string, vars: Record<string, string>, debugInfo: TrussDebugInfo];

/** A property-keyed style hash where each key owns one logical CSS property. */
export type TrussStyleHash = Record<string, TrussStyleValue>;
export type TrussCustomClassNameValue = string | ReadonlyArray<string | false | null | undefined>;
export type TrussStyleHash = Record<string, TrussStyleValue | TrussCustomClassNameValue>;

const shouldValidateTrussStyleValues = resolveShouldValidateTrussStyleValues();
const TRUSS_CSS_CHUNKS = "__trussCssChunks__";
Expand All @@ -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") {
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading