├── .eslintrc.json
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── jest-setup.ts
├── jest.config.js
├── package-lock.json
├── package.json
├── src
├── index.ts
├── styled.test.tsx
├── styled.tsx
└── types.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parser": "@typescript-eslint/parser",
4 | "plugins": ["@typescript-eslint"],
5 | "extends": [
6 | "eslint:recommended",
7 | "plugin:@typescript-eslint/eslint-recommended",
8 | "plugin:@typescript-eslint/recommended"
9 | ],
10 | "rules": {
11 | "@typescript-eslint/no-explicit-any": "off"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches:
8 | - '*'
9 |
10 | jobs:
11 | test:
12 | runs-on: ${{ matrix.os }}
13 | timeout-minutes: 20
14 |
15 | strategy:
16 | matrix:
17 | node-version: [16.x]
18 | os: [ubuntu-latest]
19 |
20 | steps:
21 | - uses: actions/checkout@v2
22 | - name: Use Node.js ${{ matrix.node-version }}
23 | uses: actions/setup-node@v1
24 | with:
25 | node-version: ${{ matrix.node-version }}
26 | - run: npm install
27 | - name: 'Run tests'
28 | run: npm run test -- --coverage
29 | - name: 'Run linter'
30 | run: npm run lint
31 | - name: Upload coverage to Codecov
32 | uses: codecov/codecov-action@v3
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /dist
13 |
14 | # misc
15 | .DS_Store
16 | *.pem
17 |
18 | # debug
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
23 | # typescript
24 | *.tsbuildinfo
25 |
26 | # Yarn 2 build artifacts
27 | # https://yarnpkg.com/advanced/qa#which-files-should-be-gitignored
28 | .yarn/*
29 | !.yarn/releases
30 | !.yarn/plugins
31 | !.yarn/sdks
32 | !.yarn/versions
33 | .pnp.cjs
34 | .pnp.loader.mjs
35 |
36 | # Editor files
37 | .idea
38 | .vscode
39 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "trailingComma": "es5",
4 | "singleQuote": true,
5 | "printWidth": 80,
6 | "tabWidth": 2
7 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Slicknode LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Slicknode Stylemapper
2 |
3 | [](https://www.npmjs.com/package/@slicknode/stylemapper)
4 | [](https://github.com/slicknode/stylemapper/actions/workflows/main.yml)
5 | [](https://app.codecov.io/github/slicknode/stylemapper)
6 | [](https://github.com/slicknode/stylemapper/blob/main/LICENSE)
7 | [](https://www.npmjs.com/package/@slicknode/stylemapper)
8 | [](https://www.npmjs.com/package/@slicknode/stylemapper)
9 |
10 | Easily create styled, strictly typed React components and simplify your component code.
11 |
12 | Stylemapper is a small, flexible and zero-dependency utility to **add CSS classes to React components**. It eliminates the boilerplate you usually need for changing styles based on state, define typescript definitions, etc. This simplifies the creation and maintenance of your style and design system:
13 |
14 | - Get **strictly typed components** without writing Typescript prop type definitions (Stylemapper infers types automatically)
15 | - Automatically create **variant props** without complicating your component code (success/error, large/medium etc.)
16 | - **Add styles to 3rd party libraries** without manually creating wrapper components, type definitions, etc.
17 | - Have a **single source of truth for your styles** instead of spreading classnames all over your React components
18 |
19 | Works especially great with utility based CSS frameworks like [Tailwind CSS](https://tailwindcss.com/).
20 |
21 | ## Installation
22 |
23 | Add Slicknode Stylemapper as a dependency to your project:
24 |
25 | npm install --save @slicknode/stylemapper
26 |
27 | ## Usage
28 |
29 | Import the `styled` utility function and create styled components. Examples are using [Tailwind CSS](https://tailwindcss.com/) utility classes.
30 |
31 | ### Basic Example
32 |
33 | ```ts
34 | import { styled } from '@slicknode/stylemapper';
35 |
36 | // Create styled components with CSS classes
37 | const Menu = styled('ul', 'space-x-2 flex');
38 | const MenuItem = styled('li', 'w-9 h-9 flex items-center justify-center');
39 |
40 | // Then use the components in your app
41 | const App = () => {
42 | return (
43 |
48 | );
49 | };
50 | ```
51 |
52 | ### Variants
53 |
54 | Create variants by passing a configuration object. Stylemapper automatically infers the correct prop type definitions and passes the resulting `className` prop to the component:
55 |
56 | ```ts
57 | const Button = styled('button', {
58 | variants: {
59 | intent: {
60 | neutral: 'bg-slate-300 border border-slate-500',
61 | danger: 'bg-red-300 border border-red-500',
62 | success: 'bg-green-300 border border-green-500',
63 | },
64 | size: {
65 | small: 'p-2',
66 | medium: 'p-4',
67 | large: 'p-8',
68 | },
69 | // Add any number of variants...
70 | },
71 | // Optionally set default variant values
72 | defaultVariants: {
73 | intent: 'neutral',
74 | size: 'medium',
75 | },
76 | });
77 |
78 | const App = () => {
79 | return (
80 |
83 | );
84 | };
85 | ```
86 |
87 | ### Compound Variants
88 |
89 | If you only want to add class names to a component if multiple props have particular values, you can configure `compountVariants`:
90 |
91 | ```ts
92 | const Button = styled('button', {
93 | variants: {
94 | intent: {
95 | danger: 'bg-red-300',
96 | success: 'bg-green-300',
97 | },
98 | outline: {
99 | true: 'border',
100 | false: '',
101 | },
102 | // Add any number of variants...
103 | },
104 | compoundVariants: [
105 | {
106 | intent: 'success',
107 | outline: true,
108 | className: 'border-green-500',
109 | },
110 | {
111 | intent: 'danger',
112 | outline: true,
113 | className: 'border-red-500',
114 | },
115 | ],
116 | });
117 |
118 | const App = () => {
119 | return (
120 |
123 | );
124 | };
125 | ```
126 |
127 | ### Custom Components
128 |
129 | Stylemapper works with any React component, as long as the component has a `className` prop. This makes it easy to add styles to your own components or to UI libraries like [Headless UI](https://headlessui.com/), [Radix UI](https://www.radix-ui.com/) and [Reach UI](https://reach.tech/). Just pass in the component as a first argument:
130 |
131 | ```ts
132 | const CustomComponent = ({ className }) => {
133 | return (
134 | // Make sure you add the className from the props to the DOM node
135 | My custom react component
136 | );
137 | };
138 |
139 | const StyledCustomComponent = styled(CustomComponent, {
140 | variants: {
141 | intent: {
142 | danger: 'bg-red-300 border border-red-500',
143 | success: 'bg-green-300 border border-green-500',
144 | },
145 | },
146 | });
147 |
148 | // Extending styled components
149 | const SizedComponent = styled(StyledCustomComponent, {
150 | variants: {
151 | size: {
152 | small: 'p-2',
153 | medium: 'p-4',
154 | large: 'p-8',
155 | },
156 | },
157 | });
158 |
159 | const App = () => {
160 | return (
161 |
162 | Large error message
163 |
164 | );
165 | };
166 | ```
167 |
168 | ### Variant Type Casting
169 |
170 | You can define `boolean` and `numeric` variant values. The type definition for the resulting prop is automatically inferred:
171 |
172 | ```ts
173 | const StyledComponent = styled('div', {
174 | variants: {
175 | selected: {
176 | true: 'bg-red-300 border border-red-500',
177 | false: 'bg-green-300 border border-green-500',
178 | },
179 | size: {
180 | 1: 'p-2'
181 | 2: 'p-4'
182 | 3: 'p-8'
183 | }
184 | },
185 | });
186 |
187 | const App = () => (
188 | // This component now expects a boolean and a number value as props
189 |
190 | );
191 | ```
192 |
193 | ### Prop Forwarding
194 |
195 | By default, variant props are **not** passed to the wrapped component to prevent invalid props to be attached to DOM nodes. If you need the values of variants inside of your custom components, specify them in the configuration:
196 |
197 | ```ts
198 | const CustomComponent = ({ open, className }) => {
199 | return (
200 | Component is {open ? 'open' : 'closed'}
201 | );
202 | };
203 |
204 | const StyledComponent = styled('div', {
205 | variants: {
206 | selected: {
207 | true: 'bg-red-300 border border-red-500',
208 | false: 'bg-green-300 border border-green-500',
209 | },
210 | },
211 | forwardProps: ['selected'],
212 | });
213 | ```
214 |
215 | ### Composing Configurations
216 |
217 | You can pass any number of configurations to the `styled` function. This allows you to reuse styles and variants across components. Pass either a string with class names or a configuration object as input values:
218 |
219 | ```ts
220 | import { intentVariants, sizeVariants } from './shared';
221 | const StyledComponent = styled(
222 | 'div',
223 |
224 | // Add some base styles that are added every time
225 | 'p-2 flex gap-2',
226 |
227 | // Add imported variants
228 | intentVariants,
229 | sizeVariants,
230 |
231 | // Add other custom variants
232 | {
233 | selected: {
234 | true: 'border border-green-500',
235 | false: 'border border-green-100',
236 | },
237 | }
238 | );
239 | ```
240 |
241 | ### IntelliSense for Tailwind CSS
242 |
243 | If you are using the offical [TailwindCSS extension for VSCode](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss), you can enable intellisense for style mapper by updating your [settings](https://code.visualstudio.com/docs/getstarted/settings):
244 |
245 | ```json
246 | {
247 | "tailwindCSS.experimental.classRegex": [
248 | ["styled\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
249 | ]
250 | }
251 | ```
252 |
253 | ## Credits
254 |
255 | This library is heavily inspired by [Stitches](https://stitches.dev/), a great CSS in Javascript library. Stylemapper brings a similar API to utility based CSS frameworks without requiring a specific library.
256 |
--------------------------------------------------------------------------------
/jest-setup.ts:
--------------------------------------------------------------------------------
1 | // In your own jest-setup.js (or any other name)
2 | import '@testing-library/jest-dom';
3 |
4 | export {};
5 | declare global {
6 | namespace jest {
7 | interface Matchers {
8 | toHaveStyleRule(attr: string, value: string): R;
9 | }
10 | }
11 | }
12 |
13 | let consoleErrorMock: { mockRestore: () => void };
14 |
15 | // React throws deprecation warnings when used without createRoot.
16 | // Supress those errors in test until testing-library supports that by default.
17 | beforeEach(() => {
18 | const originalConsoleError = console.error;
19 | consoleErrorMock = jest
20 | .spyOn(console, 'error')
21 | .mockImplementation((message, ...optionalParams) => {
22 | // Ignore ReactDOM.render/ReactDOM.hydrate deprecation warning
23 | if (message.indexOf('Use createRoot instead.') !== -1) {
24 | return;
25 | }
26 | originalConsoleError(message, ...optionalParams);
27 | });
28 | });
29 |
30 | afterEach(() => {
31 | consoleErrorMock.mockRestore();
32 | });
33 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | globals: {
3 | 'ts-jest': {
4 | tsconfig: 'tsconfig.json',
5 | },
6 | },
7 | testEnvironment: 'jsdom',
8 | roots: ['/src'],
9 | testMatch: ['**/?(*.)+(test).+(ts|tsx|js)'],
10 | transform: {
11 | '^.+\\.(ts|tsx)$': 'ts-jest',
12 | },
13 | moduleNameMapper: {
14 | '@slicknode/stylemapper': '/src',
15 | },
16 | watchPlugins: [
17 | 'jest-watch-typeahead/filename',
18 | 'jest-watch-typeahead/testname',
19 | ],
20 | setupFilesAfterEnv: ['/jest-setup.ts'],
21 | };
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@slicknode/stylemapper",
3 | "version": "0.1.5",
4 | "description": "Flexible utility to create styled and type-safe React components",
5 | "source": "src/index.ts",
6 | "main": "dist/index.js",
7 | "types": "dist/index.d.ts",
8 | "scripts": {
9 | "clean": "rm -rf dist",
10 | "prepare": "npm run clean && npm run build",
11 | "build": "tsc",
12 | "test": "jest",
13 | "test:watch": "jest --watch",
14 | "lint": "eslint src && prettier --check src",
15 | "format": "prettier --write src/ package.json",
16 | "version": "npm version"
17 | },
18 | "author": "Slicknode LLC",
19 | "license": "MIT",
20 | "devDependencies": {
21 | "@testing-library/jest-dom": "^5.16.5",
22 | "@testing-library/react": "^13.3.0",
23 | "@testing-library/user-event": "^14.4.2",
24 | "@types/jest": "^28.1.6",
25 | "@types/react": "^18.0.15",
26 | "@typescript-eslint/eslint-plugin": "^5.32.0",
27 | "@typescript-eslint/parser": "^5.32.0",
28 | "eslint": "8.4.0",
29 | "jest": "^28.1.3",
30 | "jest-environment-jsdom": "^28.1.3",
31 | "jest-watch-typeahead": "^2.0.0",
32 | "prettier": "^2.5.1",
33 | "react": "~18.2.0",
34 | "react-dom": "^18.2.0",
35 | "ts-jest": "^28.0.7",
36 | "typescript": "^4.7.4"
37 | },
38 | "directories": {
39 | "lib": "./dist"
40 | },
41 | "files": [
42 | "README.md",
43 | "LICENSE",
44 | "dist"
45 | ],
46 | "keywords": [
47 | "react",
48 | "css",
49 | "style",
50 | "classname",
51 | "classnames",
52 | "utility",
53 | "typescript",
54 | "tailwindcss"
55 | ],
56 | "repository": "slicknode/stylemapper",
57 | "peerDependencies": {
58 | "react": "^16 || ^17 || ^18"
59 | },
60 | "engines": {
61 | "node": ">=12"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { styled, styled as default } from './styled';
2 | export type { StyledComponent, StyledComponentProps } from './types';
3 |
--------------------------------------------------------------------------------
/src/styled.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { styled } from './styled';
3 | import { render, waitFor } from '@testing-library/react';
4 | import userEvent from '@testing-library/user-event';
5 |
6 | describe('styled', () => {
7 | const TestComponent = (props: { open?: boolean; className?: string }) => {
8 | return (
9 | {props.open ? 'open' : 'closed'}
10 | );
11 | };
12 |
13 | it('adds boolean true variant prop', () => {
14 | const StyledComponent = styled(TestComponent, {
15 | variants: {
16 | test: {
17 | true: 'open',
18 | },
19 | },
20 | });
21 | render();
22 | });
23 |
24 | it('forwards props that have no variant classes', () => {
25 | const className = 'test-class';
26 | const StyledComponent = styled(TestComponent, {});
27 | const { getByText } = render(
28 |
29 | );
30 |
31 | const label = getByText('open');
32 | expect(label).toHaveClass(className);
33 | });
34 |
35 | it('adds className via configuration', () => {
36 | const className = 'some-class';
37 | const StyledComponent = styled(TestComponent, className);
38 | const { container } = render();
39 | expect(container.firstChild).toHaveClass(className);
40 | });
41 |
42 | it('combines multiple classnames via configurations', () => {
43 | const className1 = 'some-class1';
44 | const className2 = 'some-class2';
45 | const StyledComponent = styled(TestComponent, className1, className2);
46 | const { container } = render();
47 | expect(container.firstChild).toHaveClass(className1);
48 | expect(container.firstChild).toHaveClass(className2);
49 | });
50 |
51 | it('adds className via configuration + prop', () => {
52 | const className = 'some-class';
53 | const otherClassName = 'other-class';
54 | const StyledComponent = styled(TestComponent, className);
55 | const { container } = render(
56 |
57 | );
58 | expect(container.firstChild).toHaveClass(className);
59 | expect(container.firstChild).toHaveClass(otherClassName);
60 | });
61 |
62 | it('merges wrapped component props with variant props', () => {
63 | const class1 = 'class1';
64 | const StyledComponent = styled(TestComponent, {
65 | variants: {
66 | test: {
67 | true: class1,
68 | },
69 | },
70 | });
71 | const { container } = render();
72 | expect(container.firstChild).toHaveClass(class1);
73 | });
74 |
75 | it('merges multiple variant configs', () => {
76 | const class1 = 'class1';
77 | const class2 = 'class2';
78 | const StyledComponent = styled(
79 | TestComponent,
80 | {
81 | variants: {
82 | prop1: {
83 | value1: class1,
84 | },
85 | },
86 | defaultVariants: {
87 | prop1: 'value1',
88 | },
89 | forwardProps: ['prop1'],
90 | },
91 | {
92 | variants: {
93 | prop2: {
94 | value2: class2,
95 | },
96 | },
97 | }
98 | );
99 | const { container } = render(
100 |
101 | );
102 | expect(container.firstChild).toHaveClass(class1);
103 | expect(container.firstChild).toHaveClass(class2);
104 | });
105 |
106 | it('merges variant config + class config + prop', () => {
107 | const class1 = 'class1';
108 | const class2 = 'class2';
109 | const class3 = 'class3';
110 | const StyledComponent = styled(TestComponent, class1, {
111 | variants: {
112 | prop1: {
113 | value1: class2,
114 | },
115 | },
116 | });
117 |
118 | const { container } = render(
119 |
120 | );
121 | expect(container.firstChild).toHaveClass(class1);
122 | expect(container.firstChild).toHaveClass(class2);
123 | expect(container.firstChild).toHaveClass(class3);
124 | });
125 |
126 | it('creates component from itrinsic HTML element key', () => {
127 | const elementNames: (keyof JSX.IntrinsicElements)[] = [
128 | 'div',
129 | 'span',
130 | 'h1',
131 | 'h2',
132 | 'h3',
133 | 'h4',
134 | 'h5',
135 | 'h6',
136 | 'p',
137 | 'a',
138 | 'ul',
139 | 'ol',
140 | 'li',
141 | 'table',
142 | 'form',
143 | 'fieldset',
144 | 'legend',
145 | 'label',
146 | 'input',
147 | 'button',
148 | 'select',
149 | 'option',
150 | 'textarea',
151 | 'article',
152 | 'aside',
153 | 'header',
154 | 'footer',
155 | 'nav',
156 | 'section',
157 | 'main',
158 | 'dialog',
159 | 'summary',
160 | 'details',
161 | 'menu',
162 | ];
163 |
164 | elementNames.forEach((elementName) => {
165 | const class1 = 'class1';
166 | const StyledComponent = styled(elementName, {
167 | variants: {
168 | active: {
169 | true: class1,
170 | },
171 | },
172 | });
173 | const { container } = render();
174 | expect(container.firstChild).toHaveClass(class1);
175 | });
176 | });
177 |
178 | it('passes through builtin component props', async () => {
179 | const class1 = 'class1';
180 | const CONTENT_TEXT = 'LABEL';
181 | const StyledComponent = styled('div', {
182 | variants: {
183 | active: {
184 | true: class1,
185 | },
186 | },
187 | });
188 | const handleClick = jest.fn();
189 | const { getByText } = render(
190 |
191 | {CONTENT_TEXT}
192 |
193 | );
194 | const label = getByText(CONTENT_TEXT);
195 | expect(label).toHaveClass(class1);
196 | userEvent.click(label);
197 | await waitFor(() => {
198 | expect(handleClick).toHaveBeenCalled();
199 | });
200 | });
201 |
202 | it('does not forward variant props', () => {
203 | const classOpen = 'open';
204 | const classClosed = 'closed';
205 | const StyledComponent = styled(TestComponent, {
206 | variants: {
207 | open: {
208 | true: classOpen,
209 | false: classClosed,
210 | },
211 | },
212 | });
213 | const { getByText } = render();
214 | const component = getByText('closed');
215 | expect(component).toHaveClass(classOpen);
216 | });
217 |
218 | it('does forward variant props configured via forwardProps', () => {
219 | const classOpen = 'open';
220 | const classClosed = 'closed';
221 | const StyledComponent = styled(TestComponent, {
222 | variants: {
223 | open: {
224 | true: classOpen,
225 | false: classClosed,
226 | },
227 | },
228 | forwardProps: ['open'],
229 | });
230 | const { getByText } = render();
231 | const component = getByText('open');
232 | expect(component).toHaveClass(classOpen);
233 | });
234 |
235 | it('ignores variant props classes with missing prop', () => {
236 | const classOpen = 'open';
237 | const classClosed = 'closed';
238 | const StyledComponent = styled(TestComponent, {
239 | variants: {
240 | open: {
241 | true: classOpen,
242 | false: classClosed,
243 | },
244 | },
245 | });
246 | const { getByText } = render();
247 | const component = getByText('closed');
248 | expect(component).not.toHaveClass(classOpen);
249 | expect(component).not.toHaveClass(classClosed);
250 | });
251 |
252 | it('sets classes via defaultVariants', () => {
253 | const classOpen = 'open';
254 | const classClosed = 'closed';
255 | const StyledComponent = styled(TestComponent, {
256 | variants: {
257 | open: {
258 | true: classOpen,
259 | false: classClosed,
260 | },
261 | },
262 | defaultVariants: {
263 | open: true,
264 | },
265 | });
266 | const { getByText } = render();
267 | const component = getByText('closed');
268 | expect(component).toHaveClass(classOpen);
269 | });
270 |
271 | it('sets compound classes via defaultVariants', () => {
272 | const classOpen = 'open';
273 | const classClosed = 'closed';
274 | const classOpenSuccess = 'open-success';
275 | const StyledComponent = styled(TestComponent, {
276 | variants: {
277 | open: {
278 | true: classOpen,
279 | false: classClosed,
280 | },
281 | success: {
282 | true: '',
283 | false: '',
284 | },
285 | },
286 | compoundVariants: [
287 | {
288 | open: true,
289 | success: true,
290 | className: classOpenSuccess,
291 | },
292 | ],
293 | defaultVariants: {
294 | open: true,
295 | success: true,
296 | },
297 | });
298 | const { getByText } = render();
299 | const component = getByText('closed');
300 | expect(component).toHaveClass(classOpenSuccess);
301 | });
302 |
303 | it('handles forwardRef props for intrinsic HTML elements', () => {
304 | const PLACEHOLDER_TEXT = 'some placeholder';
305 | const class1 = 'class1';
306 | const SearchInput = React.forwardRef<
307 | HTMLInputElement,
308 | React.ComponentProps<'input'>
309 | >((props, ref) => {
310 | const { className, ...rest } = props;
311 | return (
312 |
319 | );
320 | });
321 | const StyledComponent = styled(SearchInput, {
322 | variants: {
323 | active: {
324 | true: class1,
325 | },
326 | },
327 | });
328 |
329 | const { getByPlaceholderText } = render();
330 | const input = getByPlaceholderText(PLACEHOLDER_TEXT);
331 | expect(input).not.toHaveFocus();
332 | input.focus();
333 | expect(input).toHaveFocus();
334 | expect(input).toHaveClass(class1);
335 | });
336 |
337 | it('adds compount variant classes', () => {
338 | const class1 = 'class1';
339 | const class2 = 'class2';
340 | const class3 = 'class3';
341 | const StyledComponent = styled(TestComponent, {
342 | variants: {
343 | prop1: {
344 | value1: class1,
345 | },
346 | prop2: {
347 | value2: class2,
348 | },
349 | },
350 | compoundVariants: [
351 | {
352 | prop1: 'value1',
353 | prop2: 'value2',
354 | className: class3,
355 | },
356 | ],
357 | });
358 |
359 | const { container } = render(
360 |
361 | );
362 | expect(container.firstChild).toHaveClass(class1);
363 | expect(container.firstChild).toHaveClass(class2);
364 | expect(container.firstChild).toHaveClass(class3);
365 | });
366 |
367 | it('merges classes from multiple compount variant configurations', () => {
368 | const class1 = 'class1';
369 | const class2 = 'class2';
370 | const class3 = 'class3';
371 | const class4 = 'class4';
372 | const StyledComponent = styled(TestComponent, {
373 | variants: {
374 | prop1: {
375 | value1: class1,
376 | },
377 | prop2: {
378 | value2: class2,
379 | },
380 | },
381 | compoundVariants: [
382 | {
383 | prop1: 'value1',
384 | prop2: 'value2',
385 | className: class3,
386 | },
387 | {
388 | prop1: 'value1',
389 | prop2: 'value2',
390 | className: class4,
391 | },
392 | ],
393 | });
394 |
395 | const { container } = render(
396 |
397 | );
398 | expect(container.firstChild).toHaveClass(class1);
399 | expect(container.firstChild).toHaveClass(class2);
400 | expect(container.firstChild).toHaveClass(class3);
401 | expect(container.firstChild).toHaveClass(class4);
402 | });
403 |
404 | it('does not add classes from unmatched compound variants', () => {
405 | const class1 = 'class1';
406 | const class2 = 'class2';
407 | const class3 = 'class3';
408 | const StyledComponent = styled(TestComponent, {
409 | variants: {
410 | prop1: {
411 | value1: class1,
412 | value2: class1,
413 | },
414 | prop2: {
415 | value2: class2,
416 | },
417 | },
418 | compoundVariants: [
419 | {
420 | prop1: 'value1',
421 | prop2: 'value2',
422 | className: class3,
423 | },
424 | ],
425 | });
426 |
427 | const { container } = render(
428 |
429 | );
430 | expect(container.firstChild).toHaveClass(class1);
431 | expect(container.firstChild).toHaveClass(class2);
432 | expect(container.firstChild).not.toHaveClass(class3);
433 | });
434 | });
435 |
--------------------------------------------------------------------------------
/src/styled.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {
3 | StyledComponent,
4 | StyledComponentProps,
5 | StyleConfig,
6 | StrictValue,
7 | } from './types';
8 |
9 | export function styled<
10 | TComponent extends keyof JSX.IntrinsicElements | React.ComponentType,
11 | TConfigs extends (string | { [key: string]: unknown })[]
12 | >(
13 | Component: TComponent,
14 | ...configs: {
15 | [K in keyof TConfigs]: string extends TConfigs[K]
16 | ? TConfigs[K]
17 | :
18 | | {
19 | variants?: {
20 | [Name in string]: {
21 | [Pair in number | string]: string;
22 | };
23 | };
24 | defaultVariants?: 'variants' extends keyof TConfigs[K]
25 | ? {
26 | [Name in keyof TConfigs[K]['variants']]?: StrictValue<
27 | keyof TConfigs[K]['variants'][Name]
28 | >;
29 | }
30 | : Record;
31 | compoundVariants?: 'variants' extends keyof TConfigs[K]
32 | ? Array<
33 | {
34 | [Name in keyof TConfigs[K]['variants']]?: StrictValue<
35 | keyof TConfigs[K]['variants'][Name]
36 | >;
37 | } & { className: string }
38 | >
39 | : [];
40 | forwardProps?: 'variants' extends keyof TConfigs[K]
41 | ? (keyof TConfigs[K]['variants'])[]
42 | : [];
43 | }
44 | | string;
45 | }
46 | ): StyledComponent> &
47 | React.RefAttributes {
48 | const preparedConfig = prepareConfig(configs);
49 |
50 | const styledComponent = React.forwardRef<
51 | React.RefAttributes,
52 | React.ComponentPropsWithoutRef
53 | >((props, ref) => {
54 | const className = React.useMemo(() => {
55 | const resolvedProps = { ...preparedConfig.defaultProps, ...props };
56 | // Add variant classes
57 | let classNames = preparedConfig.variantProps.reduce((acc, propName) => {
58 | const propValue =
59 | propName in resolvedProps ? resolvedProps[propName] : undefined;
60 | if (String(propValue) in preparedConfig.variantClasses[propName]) {
61 | return acc.concat(
62 | preparedConfig.variantClasses[propName][String(propValue)]
63 | );
64 | }
65 | return acc;
66 | }, preparedConfig.defaultClassNames.concat(props.className ? props.className.split(' ') : []));
67 |
68 | // Add composite variant classes
69 | classNames = preparedConfig.compoundVariants.reduce((acc, cv) => {
70 | const { className, ...variantProps } = cv;
71 | // If all compound variable props match, add the classNames
72 | return Object.keys(variantProps).every(
73 | (propName) =>
74 | String(resolvedProps[propName]) === String(variantProps[propName])
75 | )
76 | ? acc.concat(className)
77 | : acc;
78 | }, classNames);
79 |
80 | return unique(classNames).join(' ');
81 | }, preparedConfig.variantProps.map((p) => props[p]).concat([props.className]));
82 |
83 | const forwardProps = React.useMemo(() => {
84 | const baseProps =
85 | preparedConfig.strippedProps.length > 0
86 | ? omit(props, preparedConfig.strippedProps)
87 | : props;
88 | return {
89 | ...baseProps,
90 | ref,
91 | className,
92 | };
93 | }, [props, ref, className]);
94 |
95 | return React.createElement(Component, forwardProps);
96 | });
97 |
98 | styledComponent.displayName = getDisplayName(Component);
99 |
100 | return styledComponent as any; // TODO: Figure out how to type this properly
101 | }
102 |
103 | function getDisplayName(
104 | component: keyof JSX.IntrinsicElements | React.ComponentType
105 | ) {
106 | const innerName =
107 | typeof component === 'string'
108 | ? component
109 | : component.displayName || component.name || 'Component';
110 | return `styled.${innerName}`;
111 | }
112 |
113 | type PreparedConfig = {
114 | // Classes that are applied to the component
115 | defaultClassNames: string[];
116 | // Merged variant configurations
117 | variantClasses: {
118 | [propName: string]: {
119 | // Stringified variant value
120 | [value: string]: string[];
121 | };
122 | };
123 | // Props to be stripped from the input props
124 | strippedProps: string[];
125 | forwardProps: string[];
126 | variantProps: string[];
127 | defaultProps: { [key: string]: unknown };
128 | compoundVariants: ({ [key: string]: unknown } & { className: string[] })[];
129 | };
130 |
131 | // Merges the provided configurations into a single configuration
132 | function prepareConfig(configs: StyleConfig[]): PreparedConfig {
133 | const combinedConfigs = configs.reduce(
134 | (acc, config) => {
135 | if (typeof config === 'string') {
136 | return {
137 | ...acc,
138 | defaultClassNames: acc.defaultClassNames.concat(config.split(' ')),
139 | };
140 | } else {
141 | return {
142 | ...acc,
143 | variantProps: config.variants
144 | ? acc.variantProps.concat(Object.keys(config.variants))
145 | : acc.variantProps,
146 | forwardProps: config.forwardProps
147 | ? acc.forwardProps.concat(config.forwardProps)
148 | : acc.forwardProps,
149 | defaultProps: config.defaultVariants
150 | ? {
151 | ...acc.defaultProps,
152 | ...config.defaultVariants,
153 | }
154 | : acc.defaultProps,
155 | variantClasses: config.variants
156 | ? Object.keys(config.variants).reduce((vacc, variantName) => {
157 | return {
158 | ...vacc,
159 | [variantName]: Object.entries(
160 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
161 | config.variants![variantName]
162 | ).reduce((valueAcc, [value, className]) => {
163 | if (!(String(value) in valueAcc)) {
164 | valueAcc[String(value)] = className
165 | .split(' ')
166 | .filter((c) => c);
167 | }
168 | return {
169 | ...valueAcc,
170 | [String(value)]: [
171 | ...(String(value) in valueAcc
172 | ? valueAcc[String(value)]
173 | : []),
174 | ...className.split(' ').filter((c) => c),
175 | ],
176 | };
177 | }, acc.variantClasses[variantName] || {}),
178 | };
179 | }, acc.variantClasses)
180 | : acc.variantClasses,
181 | compoundVariants: config.compoundVariants
182 | ? [
183 | ...acc.compoundVariants,
184 | ...config.compoundVariants.map((cv) => ({
185 | ...cv,
186 | className: cv.className.split(' '),
187 | })),
188 | ]
189 | : acc.compoundVariants,
190 | };
191 | }
192 | },
193 | {
194 | defaultClassNames: [],
195 | variantClasses: {},
196 | strippedProps: [],
197 | variantProps: [],
198 | forwardProps: [],
199 | defaultProps: {},
200 | compoundVariants: [],
201 | }
202 | );
203 |
204 | return {
205 | ...combinedConfigs,
206 | forwardProps: unique(combinedConfigs.forwardProps),
207 | variantProps: unique(combinedConfigs.variantProps),
208 | defaultClassNames: unique(combinedConfigs.defaultClassNames),
209 | strippedProps: unique(
210 | combinedConfigs.variantProps.filter(
211 | (p) => !combinedConfigs.forwardProps.includes(p)
212 | )
213 | ),
214 | };
215 | }
216 |
217 | /**
218 | * Creates a new object without the specified keys
219 | *
220 | * @param obj
221 | * @param keys
222 | * @returns
223 | */
224 | function omit(
225 | obj: T,
226 | keys: string[]
227 | ): Pick> {
228 | const newObj = { ...obj };
229 | keys.forEach((key) => delete (newObj as any)[key]);
230 | return newObj;
231 | }
232 |
233 | function unique(array: T[]): T[] {
234 | return array.filter((item, index, arr) => {
235 | return arr.indexOf(item) === index;
236 | });
237 | }
238 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type Assign<
2 | T1 = Record,
3 | T2 = Record
4 | > = T1 extends any ? Omit & T2 : never;
5 |
6 | export type IntrinsicElementsKeys = keyof JSX.IntrinsicElements;
7 |
8 | export type StyleConfig =
9 | | {
10 | variants?: {
11 | [Name in string]: {
12 | [Pair in number | string]: string;
13 | };
14 | };
15 | forwardProps?: string[];
16 | defaultVariants?: {
17 | [Name in string]: string | number | boolean;
18 | };
19 | compoundVariants?: ({
20 | [Name in string]: string | number | boolean;
21 | } & { className: string })[];
22 | }
23 | | string;
24 |
25 | export type StyledComponentProps = (TConfig[0] extends {
26 | variants: { [name: string]: unknown };
27 | }
28 | ? {
29 | [K in keyof TConfig[0]['variants']]?: StrictValue<
30 | keyof TConfig[0]['variants'][K]
31 | >;
32 | }
33 | : unknown) &
34 | (TConfig extends [first: any, ...rest: infer V]
35 | ? StyledComponentProps
36 | : unknown);
37 |
38 | export type StrictValue = T extends number
39 | ? T
40 | : T extends 'true'
41 | ? boolean
42 | : T extends 'false'
43 | ? boolean
44 | : T extends `${number}`
45 | ? number
46 | : T;
47 |
48 | export interface StyledComponent
49 | extends React.ForwardRefExoticComponent<
50 | Assign<
51 | TType extends IntrinsicElementsKeys | React.ComponentType
52 | ? React.ComponentPropsWithRef
53 | : never,
54 | TProps
55 | >
56 | > {
57 | (
58 | props: Assign<
59 | TType extends IntrinsicElementsKeys | React.ComponentType
60 | ? React.ComponentPropsWithRef
61 | : Record,
62 | TProps
63 | >
64 | ): React.ReactElement | null;
65 | }
66 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": false,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "esModuleInterop": true,
10 | "module": "commonjs",
11 | "declaration": true,
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "react",
16 | "incremental": true,
17 | "baseUrl": ".",
18 | "outDir": "./dist"
19 | },
20 | "include": ["src/**/*"],
21 | "exclude": ["node_modules"]
22 | }
23 |
--------------------------------------------------------------------------------