Skip to content

Commit 490db69

Browse files
John Richard Chipps-Hardingnattog
andauthored
Better Typing - Compound Variants (#11)
* Better Typing * refactor * tidy * More tidy * version bump * tidy * linting * Docs * Refactor * doh! * Update src/type.ts Co-authored-by: Guy Purssell <[email protected]> * PR Feedback * More tidy Co-authored-by: Guy Purssell <[email protected]>
1 parent 59edaec commit 490db69

File tree

6 files changed

+162
-71
lines changed

6 files changed

+162
-71
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@phntms/css-components",
33
"description": "At its core, css-components is a simple wrapper around standard CSS. It allows you to write your CSS how you wish then compose them into a component ready to be used in React.",
4-
"version": "0.0.6",
4+
"version": "0.0.7",
55
"main": "lib/index.js",
66
"types": "lib/index.d.ts",
77
"homepage": "https://github.com/phantomstudios/css-components#readme",

src/index.ts

Lines changed: 16 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,21 @@
1-
import { createElement, forwardRef, JSXElementConstructor } from "react";
1+
import { createElement, forwardRef } from "react";
22

3-
export type CSSComponentPropType<
4-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5-
C extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>,
6-
P extends keyof React.ComponentProps<C>
7-
> = React.ComponentProps<C>[P];
3+
import {
4+
CSSComponentConfig,
5+
cssType,
6+
PolymorphicComponent,
7+
variantsType,
8+
} from "./type";
9+
import { findMatchingCompoundVariants, flattenCss } from "./utils";
810

9-
type variantValue = string | number | boolean | string[];
10-
11-
// An object of variants, and how they map to CSS styles
12-
type variantsType = Partial<{
13-
[key: string]: { [key: string | number]: string | string[] };
14-
}>;
15-
16-
type compoundVariantType = {
17-
[key: string]: variantValue;
18-
} & {
19-
css: string | string[];
20-
};
21-
22-
// Does the type being passed in look like a boolean? If so, return the boolean.
23-
type BooleanIfStringBoolean<T> = T extends "true" | "false" ? boolean : T;
24-
25-
const findMatchingCompoundVariants = (
26-
compoundVariants: {
27-
[key: string]: variantValue;
28-
}[],
29-
props: {
30-
[key: string]: variantValue;
31-
}
32-
) =>
33-
compoundVariants.filter((compoundVariant) =>
34-
Object.keys(compoundVariant).every(
35-
(key) => key === "css" || compoundVariant[key] === props[key]
36-
)
37-
);
38-
39-
// Source: https://github.com/emotion-js/emotion/blob/master/packages/styled-base/types/helper.d.ts
40-
// A more precise version of just React.ComponentPropsWithoutRef on its own
41-
export type PropsOf<
42-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
43-
C extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>
44-
> = JSX.LibraryManagedAttributes<C, React.ComponentPropsWithoutRef<C>>;
45-
46-
interface Config<V> {
47-
css?: string | string[];
48-
variants?: V;
49-
compoundVariants?: compoundVariantType[];
50-
defaultVariants?: {
51-
[Property in keyof V]?: BooleanIfStringBoolean<keyof V[Property]>;
52-
};
53-
}
11+
export { CSSComponentConfig, CSSComponentPropType } from "./type";
5412

5513
export const styled = <
5614
V extends variantsType | object,
5715
E extends React.ElementType
5816
>(
5917
element: E,
60-
config?: Config<V>
18+
config?: CSSComponentConfig<V>
6119
) => {
6220
const styledComponent = forwardRef<E, { [key: string]: string }>(
6321
(props, ref) => {
@@ -71,26 +29,21 @@ export const styled = <
7129
// Pass through an existing className if it exists
7230
if (props.className) componentStyles.push(props.className);
7331

32+
// Add the base style(s)
33+
if (config?.css) componentStyles.push(flattenCss(config.css));
34+
7435
// Pass through the ref
7536
if (ref) componentProps.ref = ref;
7637

77-
// Add the base style(s)
78-
if (config?.css)
79-
componentStyles.push(
80-
Array.isArray(config.css) ? config.css.join(" ") : config.css
81-
);
82-
8338
// Apply any variant styles
8439
Object.keys(mergedProps).forEach((key) => {
8540
if (config?.variants && config.variants.hasOwnProperty(key)) {
8641
const variant = config.variants[key as keyof typeof config.variants];
8742
if (variant && variant.hasOwnProperty(mergedProps[key])) {
8843
const selector = variant[
8944
mergedProps[key] as keyof typeof variant
90-
] as string | string[];
91-
componentStyles.push(
92-
Array.isArray(selector) ? selector.join(" ") : selector
93-
);
45+
] as cssType;
46+
componentStyles.push(flattenCss(selector));
9447
}
9548
} else {
9649
componentProps[key] = props[key];
@@ -119,9 +72,5 @@ export const styled = <
11972
}
12073
);
12174

122-
return styledComponent as React.FC<
123-
React.ComponentProps<E> & {
124-
[Property in keyof V]?: BooleanIfStringBoolean<keyof V[Property]>;
125-
}
126-
>;
75+
return styledComponent as PolymorphicComponent<E, V>;
12776
};

src/type.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Mostly lifted from here:
3+
* https://www.benmvp.com/blog/forwarding-refs-polymorphic-react-component-typescript/
4+
*
5+
* Much respect
6+
*/
7+
8+
import { JSXElementConstructor } from "react";
9+
10+
// Source: https://github.com/emotion-js/emotion/blob/master/packages/styled-base/types/helper.d.ts
11+
// A more precise version of just React.ComponentPropsWithoutRef on its own
12+
export type PropsOf<
13+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
14+
C extends keyof JSX.IntrinsicElements | React.JSXElementConstructor<any>
15+
> = JSX.LibraryManagedAttributes<C, React.ComponentPropsWithoutRef<C>>;
16+
17+
/**
18+
* Allows for extending a set of props (`ExtendedProps`) by an overriding set of props
19+
* (`OverrideProps`), ensuring that any duplicates are overridden by the overriding
20+
* set of props.
21+
*/
22+
export type ExtendableProps<
23+
ExtendedProps = Record<string, unknown>,
24+
OverrideProps = Record<string, unknown>
25+
> = OverrideProps & Omit<ExtendedProps, keyof OverrideProps>;
26+
27+
/**
28+
* Allows for inheriting the props from the specified element type so that
29+
* props like children, className & style work, as well as element-specific
30+
* attributes like aria roles. The component (`C`) must be passed in.
31+
*/
32+
export type InheritableElementProps<
33+
C extends React.ElementType,
34+
Props = Record<string, unknown>
35+
> = ExtendableProps<PropsOf<C>, Props>;
36+
37+
export type PolymorphicRef<C extends React.ElementType> =
38+
React.ComponentPropsWithRef<C>["ref"];
39+
40+
export type PolymorphicComponentProps<
41+
C extends React.ElementType,
42+
Props = Record<string, unknown>
43+
> = InheritableElementProps<C, Props>;
44+
45+
export type PolymorphicComponentPropsWithRef<
46+
C extends React.ElementType,
47+
Props = Record<string, unknown>
48+
> = PolymorphicComponentProps<C, Props> & { ref?: PolymorphicRef<C> };
49+
50+
/**
51+
* Pass in an element type `E` and a variants `V` and get back a
52+
* type that can be used to create a component.
53+
*/
54+
55+
export type PolymorphicComponent<
56+
E extends React.ElementType,
57+
V extends variantsType | object
58+
> = React.FC<PolymorphicComponentPropsWithRef<E, VariantOptions<V>>>;
59+
60+
/**
61+
* The CSS Component Config type.
62+
*/
63+
export interface CSSComponentConfig<V> {
64+
css?: cssType;
65+
variants?: V;
66+
compoundVariants?: CompoundVariantType<V>[];
67+
defaultVariants?: {
68+
[Property in keyof V]?: BooleanIfStringBoolean<keyof V[Property]>;
69+
};
70+
}
71+
72+
/**
73+
* Allows you to extract a type for variant values.
74+
*/
75+
export type CSSComponentPropType<
76+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
77+
C extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>,
78+
P extends keyof React.ComponentProps<C>
79+
> = React.ComponentProps<C>[P];
80+
81+
/**
82+
* CSS can be passed in as either a string or an array of strings.
83+
*/
84+
export type cssType = string | string[];
85+
86+
export type variantValue = string | number | boolean | string[];
87+
88+
/**
89+
* An object of variants, and how they map to CSS styles
90+
*/
91+
export type variantsType = Partial<{
92+
[key: string]: { [key: string | number]: cssType };
93+
}>;
94+
95+
/**
96+
* Returns a boolean type if a "true" or "false" string type is passed in.
97+
*/
98+
export type BooleanIfStringBoolean<T> = T extends "true" | "false"
99+
? boolean
100+
: T;
101+
102+
/**
103+
* Returns a type object containing the variants and their possible values.
104+
*/
105+
export type VariantOptions<V> = {
106+
[Property in keyof V]?: BooleanIfStringBoolean<keyof V[Property]>;
107+
};
108+
109+
/**
110+
* Returns a type object for compound variants.
111+
*/
112+
export type CompoundVariantType<V> = VariantOptions<V> & {
113+
css: cssType;
114+
};

src/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { cssType, variantValue } from "./type";
2+
3+
export const findMatchingCompoundVariants = (
4+
compoundVariants: {
5+
[key: string]: variantValue;
6+
}[],
7+
props: {
8+
[key: string]: variantValue;
9+
}
10+
) =>
11+
compoundVariants.filter((compoundVariant) =>
12+
Object.keys(compoundVariant).every(
13+
(key) => key === "css" || compoundVariant[key] === props[key]
14+
)
15+
);
16+
17+
export const flattenCss = (css: cssType) =>
18+
Array.isArray(css) ? css.join(" ") : css;

test/index.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ describe("supports more exotic setups", () => {
232232
expect(ref).toBeCalled();
233233
});
234234

235-
it("Should be able to inspect the variants", async () => {
235+
it("should be able to inspect the variants", async () => {
236236
const Button = styled("button", {
237237
css: "test",
238238
variants: { primary: { true: "primary" } },
@@ -242,4 +242,14 @@ describe("supports more exotic setups", () => {
242242

243243
expectTypeOf<primaryType>().toMatchTypeOf<boolean | undefined>();
244244
});
245+
246+
it("should be able to use existing props as variants", async () => {
247+
const Option = styled("option", {
248+
css: "test",
249+
variants: { selected: { true: "primary" } },
250+
});
251+
const { container } = render(<Option selected>Option 1</Option>);
252+
253+
expect(container.firstChild).toHaveClass("primary");
254+
});
245255
});

0 commit comments

Comments
 (0)