├── .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 | [![npm version](https://badge.fury.io/js/@slicknode%2Fstylemapper.svg)](https://www.npmjs.com/package/@slicknode/stylemapper) 4 | [![Build passing](https://github.com/slicknode/stylemapper/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/slicknode/stylemapper/actions/workflows/main.yml) 5 | [![Test Coverage](https://badgen.net/codecov/c/github/slicknode/stylemapper)](https://app.codecov.io/github/slicknode/stylemapper) 6 | [![License](https://badgen.net/github/license/slicknode/stylemapper)](https://github.com/slicknode/stylemapper/blob/main/LICENSE) 7 | [![Dependency Count](https://badgen.net/bundlephobia/dependency-count/@slicknode/stylemapper)](https://www.npmjs.com/package/@slicknode/stylemapper) 8 | [![Types included](https://badgen.net/npm/types/@slicknode/stylemapper)](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 | 44 | Home 45 | Product 46 | Signup Now 47 | 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 | --------------------------------------------------------------------------------