Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add parts property on createStencil for better type safety #3134

Draft
wants to merge 2 commits into
base: prerelease/minor
Choose a base branch
from
Draft
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
31 changes: 29 additions & 2 deletions modules/react/card/lib/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,38 @@ export interface CardProps extends BoxProps {

// .cnvs-card
export const cardStencil = createStencil({
base: {
vars: {
separator: '',
},
parts: {
separator: 'card-separator',
bar: 'foo',
},
base: ({separatorPart}) => ({
boxShadow: system.depth[1],
padding: system.space.x8,
backgroundColor: system.color.bg.default,
border: `${px2rem(1)} solid ${system.color.border.container}`,
borderRadius: system.shape.x2,
[`:has(${separatorPart})`]: {
backgroundColor: 'red',
},
}),
});

const testStencil = createStencil({
extends: cardStencil,
parts: {
test: 'test-part',
},
base: ({separator, testPart, separatorPart}) => ({
[`:has(${separatorPart})`]: {
color: 'red',
},
[`:has(${testPart})`]: {
backgroundColor: 'blue',
},
}),
});

/**
Expand All @@ -42,7 +67,9 @@ export const Card = createComponent('div')({
displayName: 'Card',
Component: ({children, ...elemProps}: CardProps, ref, Element) => {
return (
<Element ref={ref} {...mergeStyles(elemProps, cardStencil())}>
<Element ref={ref} {...mergeStyles(elemProps, testStencil())}>
<div data-part={testStencil.parts.separator}>foo</div>
<div data-part={testStencil.parts.test}>test</div>
{children}
</Element>
);
Expand Down
81 changes: 55 additions & 26 deletions modules/styling/lib/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ type StencilDefaultVars<
ID extends string = never
> = [E] extends [never]
? DefaultedVars<V, ID>
: E extends BaseStencil<any, infer VE, any, infer IDE>
: E extends BaseStencil<any, any, infer VE, any, infer IDE>
? DefaultedVarsMapToCSSVarNames<VE, IDE> &
DefaultedVarsMap<VE, IDE> &
DefaultedVarsMapToCSSVarNames<V, ID> &
Expand Down Expand Up @@ -795,42 +795,45 @@ export function handleCsProp<
}

type StylesReturn<
P extends Record<string, string>,
V extends DefaultedVarsShape = {},
E extends BaseStencil<any, any, any, any> = never
E extends BaseStencil<any, any, any, any, any> = never
> =
| SerializedStyles
| CSSObjectWithVars
| ((
vars: [E] extends [never]
? RequiredVars<V>
: [E] extends [BaseStencil<any, infer VE, any, any>]
? RequiredVars<VE & V>
? RequiredVars<V> & StencilVarsParts<P>
: [E] extends [BaseStencil<infer PE, any, infer VE, any, any>]
? RequiredVars<VE & V> & StencilVarsParts<PE & P>
: never
) => SerializedStyles | CSSObjectWithVars);

export type StencilModifierConfig<
P extends Record<string, string>,
V extends DefaultedVarsShape = {},
E extends BaseStencil<any, any, any, any> = never
> = Record<string, Record<string, StylesReturn<V, E>>>;
> = Record<string, Record<string, StylesReturn<P, V, E>>>;

export type StencilCompoundConfig<M> = {
modifiers: {[K in keyof M]?: MaybeBoolean<keyof M[K]>};
styles: SerializedStyles | CSSObjectWithVars;
};

type ModifierValuesStencil<
M extends StencilModifierConfig<any, any> = {},
M extends StencilModifierConfig<any, any, any> = {},
V extends DefaultedVarsShape = {}
> = {
[P in keyof M]?: P extends keyof V
? MaybeBoolean<keyof M[P]> | (string & {}) // If both modifiers and variables define the same key, the value can be either a modifier or a string
: MaybeBoolean<keyof M[P]>;
[K in keyof M]?: K extends keyof V
? MaybeBoolean<keyof M[K]> | (string & {}) // If both modifiers and variables define the same key, the value can be either a modifier or a string
: MaybeBoolean<keyof M[K]>;
};

export interface StencilConfig<
M extends Record<string, Record<string, StylesReturn<V, E>>>,
P extends Record<string, string>,
M extends Record<string, Record<string, StylesReturn<P, V, E>>>,
V extends DefaultedVarsShape = {},
E extends BaseStencil<any, any, any, any> = never,
E extends BaseStencil<any, any, any, any, any> = never,
ID extends string | never = never
> {
/**
Expand Down Expand Up @@ -862,6 +865,7 @@ export interface StencilConfig<
* extraction will use the base modifier name in the selector.
*/
extends?: E;
parts?: P;
/**
* A stencil can support CSS variables. Since CSS variables cascade by default, variables are
* defined with defaults. These defaults are added automatically to the `base` styles to prevent
Expand Down Expand Up @@ -904,7 +908,7 @@ export interface StencilConfig<
/**
* Base styles. These styles will always be returned when the stencil is called
*/
base: StylesReturn<V, E>;
base: StylesReturn<P, V, E>;
/**
* Stencil modifiers. The styles of a modifier are returned if the stencil is called with a
* modifier key that matches the modifier value. For example:
Expand Down Expand Up @@ -988,7 +992,10 @@ export interface StencilConfig<
: undefined;
}

type StencilModifierReturn<M extends StencilModifierConfig<V>, V extends DefaultedVarsShape> = {
type StencilModifierReturn<
M extends StencilModifierConfig<any, V>,
V extends DefaultedVarsShape
> = {
[K1 in keyof M]: {[K2 in keyof M[K1]]: string};
};

Expand All @@ -997,23 +1004,26 @@ type StencilDefaultModifierReturn<M> = {
};

export interface BaseStencil<
M extends StencilModifierConfig<V> = {},
P extends Record<string, string> = {},
M extends StencilModifierConfig<P, V> = {},
V extends DefaultedVarsShape = {},
E extends BaseStencil<any, any, any, any> = never,
E extends BaseStencil<any, any, any, any, any> = never,
ID extends string = never
> {
__extends?: E;
__parts?: P;
__vars: V;
__modifiers: M;
__id: ID;
}

export interface Stencil<
M extends StencilModifierConfig<V, E> = {},
P extends Record<string, string> = {},
M extends StencilModifierConfig<P, V, E> = {},
V extends DefaultedVarsShape = {},
E extends BaseStencil<any, any, any, any> = never,
ID extends string = never
> extends BaseStencil<M, V, E, ID> {
> extends BaseStencil<P, M, V, E, ID> {
(
// If this stencil extends another stencil, merge the inputs
options?: [E] extends [never]
Expand All @@ -1025,12 +1035,13 @@ export interface Stencil<
className: string;
style?: Record<string, string>;
};
parts: [E] extends [BaseStencil<infer PE, any, any, any, any>] ? PE & P : P;
vars: StencilDefaultVars<V, E, ID>;
base: string;
modifiers: [E] extends [BaseStencil<infer ME, infer VE, any, any>]
modifiers: [E] extends [BaseStencil<any, infer ME, infer VE, any, any>]
? StencilModifierReturn<ME & M, VE & V>
: StencilModifierReturn<M, V>;
defaultModifiers: [E] extends [BaseStencil<infer ME, any, any, any>]
defaultModifiers: [E] extends [BaseStencil<any, infer ME, any, any, any>]
? StencilDefaultModifierReturn<ME & M>
: StencilDefaultModifierReturn<M>;
}
Expand Down Expand Up @@ -1080,19 +1091,36 @@ export function parentModifier(value: string) {
return `.${value.replace('css-', 'm')} :where(&)`;
}

type StencilVarsParts<T> = {
[K in keyof T as `${K & string}${Capitalize<'Part'>}`]: `[data-part="${T[K] & string}"]`;
};

function makeParts<const T extends Record<string, string>>(parts: T): StencilVarsParts<T> {
if (!parts) {
return {} as StencilVarsParts<T>;
}
return Object.keys(parts).reduce((result, key: any) => {
(result as any)[`${key}Part`] = `[data-part="${parts[key]}"]`;
return result;
}, {} as StencilVarsParts<T>);
}

/**
* Creates a reuseable Stencil for styling elements. It takes vars, base styles, modifiers, and
* compound modifiers.
*/
export function createStencil<
M extends StencilModifierConfig<V>, // TODO: default to `{}` and fix inference in `StyleReturn` types so that modifier style return functions give correct inference to variables
const P extends Record<string, string>,
M extends StencilModifierConfig<P, V>, // TODO: default to `{}` and fix inference in `StyleReturn` types so that modifier style return functions give correct inference to variables
V extends DefaultedVarsShape = {},
E extends BaseStencil<any, any, any, any> = never, // use BaseStencil to avoid infinite loops
ID extends string = never
>(config: StencilConfig<M, V, E, ID>, id?: ID): Stencil<M, V, E, ID> {
const {vars, base, modifiers, compound, defaultModifiers} = config;
>(config: StencilConfig<P, M, V, E, ID>, id?: ID): Stencil<P, M, V, E, ID> {
const {parts, vars, base, modifiers, compound, defaultModifiers} = config;
const composes = config.extends as unknown as Stencil<any, any> | undefined;
const _parts = parts;
const _vars = createDefaultedVars(vars || {}, id) as any; // The return type is conditional and TypeScript doesn't like that here
const _partsVars = makeParts({...composes?.parts, ...parts});

// combine the vars keys together
Object.keys(composes?.vars || {}).forEach(key => {
Expand All @@ -1104,7 +1132,7 @@ export function createStencil<
const _base = createStyles({
..._vars.$$defaults,
boxSizing: 'border-box',
...(typeof base === 'function' ? base(_vars) : base),
...(typeof base === 'function' ? base({..._vars, ..._partsVars}) : base),
});

const composesModifier = composes?.modifiers || (() => '');
Expand All @@ -1116,7 +1144,7 @@ export function createStencil<
const modifier = modifiers[key][modifierKey];
// @ts-ignore
result[modifierKey] = createStyles(
typeof modifier === 'function' ? modifier(_vars) : modifier
typeof modifier === 'function' ? modifier({..._vars, ..._partsVars}) : modifier
);

return result;
Expand Down Expand Up @@ -1159,7 +1187,7 @@ export function createStencil<
)
: () => '';

const stencil: Stencil<M, V, E, ID> = ((input: Record<string, string>) => {
const stencil: Stencil<P, M, V, E, ID> = ((input: Record<string, string>) => {
const inputModifiers = {...composes?.defaultModifiers, ...defaultModifiers};
// Only override defaults if a value is defined
for (const key in input) {
Expand Down Expand Up @@ -1188,6 +1216,7 @@ export function createStencil<
};
}) as any;

stencil.parts = {...composes?.parts, ..._parts} as P;
stencil.vars = _vars;
stencil.base = combineClassNames([composes?.base, _base]);
stencil.modifiers = _modifiers as any; // The return type is conditional and TypeScript doesn't like that here
Expand Down
45 changes: 45 additions & 0 deletions modules/styling/spec/cs.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1309,6 +1309,51 @@ describe('cs', () => {
expect(className.split(' ')).toHaveLength(10); // 7 + 3 modifier hashes
});
});
describe('when adding parts', () => {
it('should allow to add parts for data-part attributes', () => {
const myStencil = createStencil({
parts: {
separator: 'my-separator',
},
base: {},
});

expectTypeOf(myStencil).toHaveProperty('parts');
expectTypeOf(myStencil.parts).toHaveProperty('separator');
expectTypeOf(myStencil.parts.separator).toEqualTypeOf<string>();

expect(myStencil).toHaveProperty('parts.separator', expect.stringMatching('my-separator'));
});
});
it('should handle parts and pass them to the base function as [data-part=${part-value}]', () => {
const myStencil = createStencil({
parts: {
separator: 'my-separator',
},
base: ({separatorPart}) => ({
[`${separatorPart}`]: {},
}),
});

// `.toHaveStyle` doesn't work with variables and worse, it ALWAYS passes when passed anything
// other than a parsable color string. https://github.com/testing-library/jest-dom/issues/322
// We'll resort to iterating over injected styles instead.
let found = false;
for (const sheet of document.styleSheets as any as Iterable<CSSStyleSheet>) {
for (const rule of sheet.cssRules as any as Iterable<CSSRule>) {
if (rule.cssText.includes(myStencil.base)) {
if (rule.cssText.includes(myStencil.parts.separator)) {
expect(rule.cssText).toContain(
`${myStencil.base} [data-part="${myStencil.parts.separator}"]`
);
}

found = true;
}
}
}
expect(found).toEqual(true);
});
});
});

Expand Down
Loading