├── .babelrc ├── .eslintrc ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SUPPORT.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── cli │ ├── index.ts │ └── utils.ts ├── index.ts ├── type.ts └── utils.ts ├── test ├── cli │ ├── css │ │ └── test.css │ └── scss │ │ └── test.scss └── index.test.tsx ├── tsconfig.json └── tsconfig.test.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ] 6 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true, 6 | "jest": true 7 | }, 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "jsx": true, 11 | "useJSXTextNode": true 12 | }, 13 | "settings": { 14 | "react": { 15 | "version": "detect" 16 | } 17 | }, 18 | "plugins": ["prettier", "eslint-plugin-import"], 19 | "extends": [ 20 | "plugin:react/recommended", 21 | "plugin:react-hooks/recommended", 22 | "plugin:@typescript-eslint/recommended", 23 | "plugin:prettier/recommended" 24 | ], 25 | "rules": { 26 | "react-hooks/rules-of-hooks": "error", 27 | "react-hooks/exhaustive-deps": "error", 28 | "react/react-in-jsx-scope": "off", 29 | "@typescript-eslint/explicit-function-return-type": "off", 30 | "@typescript-eslint/explicit-module-boundary-types": "off", 31 | "@typescript-eslint/ban-ts-comment": "off", 32 | "react/prop-types": "off", 33 | "import/order": [ 34 | "error", 35 | { 36 | "groups": ["builtin", "external", "internal"], 37 | "pathGroups": [ 38 | { 39 | "pattern": "react", 40 | "group": "external", 41 | "position": "before" 42 | } 43 | ], 44 | "pathGroupsExcludedImportTypes": ["react"], 45 | "newlines-between": "always", 46 | "alphabetize": { 47 | "order": "asc", 48 | "caseInsensitive": true 49 | } 50 | } 51 | ], 52 | "prettier/prettier": "error" 53 | }, 54 | "globals": { 55 | "React": "writable" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @DhiaPhntm @JPedersen @gg-phntms @balraj-johal -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: read 14 | 15 | strategy: 16 | matrix: 17 | node-version: [18.x, 20.x, 22.x] 18 | 19 | steps: 20 | - uses: actions/checkout@v1 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: npm install, build, and test 28 | run: | 29 | npm ci 30 | npm run build --if-present 31 | npm run lint 32 | npm run test 33 | env: 34 | CI: true 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | 14 | strategy: 15 | matrix: 16 | node-version: [18.x, 20.x, 22.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v1 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: npm install, build, and test 27 | run: | 28 | npm ci 29 | npm run build --if-present 30 | npm run lint 31 | npm run test 32 | env: 33 | CI: true 34 | 35 | publish-npm: 36 | needs: test 37 | runs-on: ubuntu-latest 38 | 39 | permissions: 40 | contents: read 41 | 42 | steps: 43 | - uses: actions/checkout@v2 44 | - uses: actions/setup-node@v1 45 | with: 46 | node-version: 22 47 | registry-url: https://registry.npmjs.org/ 48 | - run: npm ci 49 | - run: npm publish --access=public 50 | env: 51 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | *.log 4 | dist 5 | .cache 6 | lib 7 | !src/* 8 | !test/* 9 | .idea 10 | .DS_Store 11 | test/cli/styles.ts -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | public 5 | tmp 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment 10 | include: 11 | 12 | - Using welcoming and inclusive language 13 | - Being respectful of differing viewpoints and experiences 14 | - Gracefully accepting constructive criticism 15 | - Focusing on what is best for the community 16 | - Showing empathy towards other community members 17 | 18 | Examples of unacceptable behavior by participants include: 19 | 20 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 21 | - Trolling, insulting/derogatory comments, and personal or political attacks 22 | - Public or private harassment 23 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 24 | - Other conduct which could reasonably be considered inappropriate in a professional setting 25 | 26 | ## Our Responsibilities 27 | 28 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 29 | 30 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 31 | 32 | ## Scope 33 | 34 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 35 | 36 | ## Enforcement 37 | 38 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at developers@phntms.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 39 | 40 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 41 | 42 | ## Attribution 43 | 44 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 45 | 46 | [homepage]: https://www.contributor-covenant.org 47 | 48 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq 49 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | I'm really happy that you're interested in helping out with this little project. 4 | 5 | As this is very early days for the project there's not a lot in the way of resources, but please check out the [documentation](./README.md), and also the [list of issues](https://github.com/phantomstudios/css-components/issues). 6 | 7 | Please submit an issue if you need help with anything. 8 | 9 | We have a [code of conduct](./CODE_OF_CONDUCT.md) so please make sure you follow it. 10 | 11 | ## Submitting changes 12 | 13 | Please send a 14 | [GitHub Pull Request to css-components](https://github.com/phantomstudios/css-components/pull/new/master) with a clear list of what you've done (read more about [pull requests](https://help.github.com/en/articles/about-pull-requests)). When you send a pull request, please make sure you've covered off all the points in the template. 15 | 16 | Make sure you've read about our workflow (below); in essence make sure each Pull Request is atomic but don't worry too much about the commits themselves as we use squash-and-merge. 17 | 18 | ## Our workflow 19 | 20 | We use [GitHub flow](https://guides.github.com/introduction/flow/); it's a lot like git-flow but simpler and more forgiving. We use the `squash and merge` strategy to merge Pull Requests. 21 | 22 | In effect this means: 23 | 24 | - Don't worry about individual commits. They will be preserved, but not on the main `master` branch history, so feel free to commit early and often, using git as a save mechanism. 25 | - Your Pull Request title and description become very important; they are the history of the master branch and explain all the changes. 26 | - You ought to be able to find any previous version easily using GitHub tabs, or [Releases](https://github.com/phantomstudios/css-components/releases) 27 | 28 | Thanks, John Chipps-Harding 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Phantom Studios Ltd 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 | # @phantomstudios/css-components 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![Actions Status][ci-image]][ci-url] 5 | [![PR Welcome][npm-downloads-image]][npm-downloads-url] 6 | 7 | A simple, lightweight, and customizable CSS components library that lets you wrap your css styles in a component-like structure. Inspired by css-in-js libraries like [styled-components](https://styled-components.com/) and [stitches](https://stitches.dev/). 8 | 9 | ## Introduction 10 | 11 | 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. 12 | 13 | Here is a minimal example of a button component with an optional variant: 14 | 15 | ```ts 16 | import { styled } from "@phantomstudios/css-components"; 17 | import css from "./styles.module.css"; 18 | 19 | export const Button = styled("button", { 20 | css: css.root, 21 | variants: { 22 | primary: { 23 | true: css.primary, 24 | }, 25 | }, 26 | }); 27 | ``` 28 | 29 | This outputs a nice clean component that can be used in React: 30 | 31 | ```tsx 32 | import { Button } from "./Button"; 33 | 34 | export const App = () => ( 35 |
36 | 37 | 38 |
39 | ); 40 | ``` 41 | 42 | ## Installation 43 | 44 | Install this package with `npm`. 45 | 46 | ```bash 47 | npm i @phantomstudios/css-components 48 | ``` 49 | 50 | ## Usage 51 | 52 | Here is a full example of a button component with an optional variant called `primary`: 53 | 54 | components/Button/styles.module.css 55 | 56 | ```css 57 | .root { 58 | background-color: grey; 59 | border-radius: 4px; 60 | } 61 | 62 | .primary { 63 | background-color: black; 64 | } 65 | ``` 66 | 67 | components/Button/styles.ts 68 | 69 | ```ts 70 | import { styled } from "@phantomstudios/css-components"; 71 | import css from "./styles.module.css"; 72 | 73 | export const StyledButton = styled("button", { 74 | css: css.root, 75 | variants: { 76 | primary: { 77 | true: css.primary, 78 | }, 79 | }, 80 | }); 81 | ``` 82 | 83 | components/Button/index.tsx 84 | 85 | ```tsx 86 | import { StyledButton } from "./styles.ts"; 87 | 88 | interface Props { 89 | title: string; 90 | onClick: () => void; 91 | primary?: boolean; 92 | } 93 | 94 | export const Button = ({ title, onClick, primary }: Props) => ( 95 | 96 | {title} 97 | 98 | ); 99 | ``` 100 | 101 | ## The variants config object 102 | 103 | The variants config object is a simple object that allows you to define the variants that your component supports. Each variant is a key in the object and the value is an object that defines the possible values(css classes) for that variant. 104 | 105 | ```tsx 106 | const StyledButton = styled("button", { 107 | css: css.root, 108 | variants: { 109 | big: { 110 | // Boolean values are supported 111 | true: css.big, 112 | }, 113 | color: { 114 | // String values are supported 115 | primary: css.primary, 116 | secondary: css.secondary, 117 | }, 118 | size: { 119 | // Number values are supported 120 | 1: css.size1, 121 | 2: css.size2, 122 | }, 123 | }, 124 | }); 125 | ``` 126 | 127 | ## Default Variants 128 | 129 | You can use the `defaultVariants` feature to set a variant by default: 130 | 131 | ```tsx 132 | const StyledButton = styled("button", { 133 | css: css.root, 134 | variants: { 135 | big: { 136 | // Boolean values are supported 137 | true: css.big, 138 | }, 139 | }, 140 | defaultVariants: { 141 | big: true, 142 | }, 143 | }); 144 | ``` 145 | 146 | ## Compound Variants 147 | 148 | For more complex variant setups you can use the compound variants argument to define what styles should be applied when multiple variants are used. 149 | 150 | ```tsx 151 | const StyledButton = styled("button", { 152 | css: css.root, 153 | variants: { 154 | border: { 155 | true: css.bordered, 156 | }, 157 | color: { 158 | primary: css.primary, 159 | secondary: css.secondary, 160 | }, 161 | }, 162 | compoundVariants: [ 163 | { 164 | border: true, 165 | color: "primary", 166 | css: css.blueBorder, 167 | }, 168 | { 169 | border: true, 170 | color: "secondary", 171 | css: css.greyBorder, 172 | }, 173 | ], 174 | }); 175 | ``` 176 | 177 | ## Other 178 | 179 | ### Array of Classes 180 | 181 | Wherever you specify a css selector, you can also pass in an array of classes to help composing and reusing styles. 182 | 183 | ```tsx 184 | import { styled } from "@phantomstudios/css-components"; 185 | import shared from "../sharedstyles.module.css"; 186 | import css from "./styles.module.css"; 187 | 188 | const Link = styled("a", { 189 | css: [shared.link, shared.fontNormal, css.root], 190 | variants: { 191 | big: { 192 | true: [css.big, shared.fontBold], 193 | }, 194 | }, 195 | }); 196 | ``` 197 | 198 | ### Other Components 199 | 200 | You can also style other components from other ecosystems. As long as the component has a `className` prop, styling should propagate. 201 | 202 | Example extending the standard Next.js `Link` component: 203 | 204 | ```tsx 205 | import { styled } from "@phantomstudios/css-components"; 206 | import NextLink from "next/link"; 207 | import css from "./styles.module.css"; 208 | 209 | const Link = styled(NextLink, { 210 | css: css.link, 211 | variants: { 212 | big: { 213 | true: css.big, 214 | }, 215 | }, 216 | }); 217 | ``` 218 | 219 | ### Passthrough 220 | 221 | By default variant values do not end up propagating to the element it is extending and is exclusively used for styling the current component. This is to stop React specific runtime errors from occurring with regards to the DOM. If you do indeed want to pass a variant value to the element you are extending, you can use the `passthrough` option. 222 | 223 | In the following example, `readOnly` is an intrinsic HTML attribute that we both want to style, but also continue to pass through to the DOM element. 224 | 225 | ```tsx 226 | import { styled } from "@phantomstudios/css-components"; 227 | import css from "./styles.module.css"; 228 | 229 | const Input = styled("input", { 230 | css: css.root, 231 | variants: { 232 | readOnly: { 233 | true: css.disabledStyle, 234 | }, 235 | }, 236 | passthrough: ["readOnly"], 237 | }); 238 | ``` 239 | 240 | ### Type Helper 241 | 242 | We have included a helper that allows you to access the types of the variants you have defined. 243 | 244 | ```tsx 245 | import { VariantProps } from "@phantomstudios/css-components"; 246 | import css from "./styles.module.css"; 247 | 248 | const Button = styled("button", { 249 | css: css.baseButton, 250 | variants: { 251 | primary: { true: css.primary }, 252 | }, 253 | }); 254 | 255 | type ButtonVariants = VariantProps; 256 | type PrimaryType = ButtonVariants["primary"]; 257 | ``` 258 | 259 | ## CLI Tool (Experimental) 260 | 261 | We have included a CLI tool that allows you to generate components from CSS and SCSS files which confirm to a specific naming convention. This is highly experimental and is subject to change. 262 | 263 | Consider this CSS file: 264 | 265 | ```css 266 | /* styles.module.css */ 267 | nav.topBar { 268 | background-color: #aaa; 269 | padding: 32px; 270 | } 271 | 272 | nav.topBar_fixed_true { 273 | position: fixed; 274 | bottom: 0; 275 | left: 0; 276 | right: 0; 277 | } 278 | ``` 279 | 280 | Or using SCSS (Sassy CSS): 281 | 282 | ```scss 283 | // styles.module.scss 284 | nav.topBar { 285 | background-color: #aaa; 286 | padding: 32px; 287 | 288 | &_fixed_true { 289 | position: fixed; 290 | bottom: 0; 291 | left: 0; 292 | right: 0; 293 | } 294 | } 295 | ``` 296 | 297 | You can generate a component from this files with the following command: 298 | 299 | ```bash 300 | # For CSS 301 | npx @phantomstudios/css-components --css styles.module.css 302 | 303 | # For SCSS 304 | npx @phantomstudios/css-components --css styles.module.scss 305 | 306 | # or if you have the package installed 307 | npx css-components --css styles.module.css 308 | npx css-components --css styles.module.scss 309 | ``` 310 | 311 | This will output a file called `styles.ts` that looks like this: 312 | 313 | ```ts 314 | import { styled } from "@phantomstudios/css-components"; 315 | 316 | import css from "./test.css"; 317 | 318 | export const TopBar = styled("nav", { 319 | css: css.topBar, 320 | variants: { 321 | fixed: { 322 | true: css.topBar_fixed_true, 323 | }, 324 | }, 325 | }); 326 | ``` 327 | 328 | ### Possible CSS definitions: 329 | 330 | - `a.link` Allowing you to define a base style for the component. This means it will be an anchor tag with the css class `link`. 331 | - `a.link_big_true` Lets you set the styling for a variant called `big` with the value `true`. 332 | - `a.link_theme_light_default` Same as above but also sets the variant as the default value. 333 | - `a.link_big_true_theme_light` Gives you the ability to define compound variants styles. 334 | 335 | ### CLI Options 336 | 337 | - `--css` The path to the CSS or SCSS file you want to generate a component from. This can also be a recursive glob pattern allowing you to scan your entire components directory. 338 | - `--output` The filename for the output file. Defaults to `styles.ts` which will be saved in the same directory as the CSS file. 339 | - `--overwrite` If the output file already exists, this will overwrite it. Defaults to `false`. 340 | 341 | Example to generate components from all CSS and SCSS files in the components directory: 342 | 343 | ```bash 344 | # From CSS 345 | npx @phantomstudios/css-components --css ./src/components/**/*.css --output styles.ts 346 | 347 | # Or from SCSS 348 | npx @phantomstudios/css-components --css ./src/components/**/*.scss --output styles.ts 349 | 350 | # Or from both CSS and SCSS 351 | npx @phantomstudios/css-components --css ./src/components/**/*.{css,scss} --output styles.ts 352 | ``` 353 | 354 | [npm-image]: https://img.shields.io/npm/v/@phantomstudios/css-components.svg?style=flat-square&logo=react 355 | [npm-url]: https://npmjs.org/package/@phantomstudios/css-components 356 | [npm-downloads-image]: https://img.shields.io/npm/dm/@phantomstudios/css-components.svg 357 | [npm-downloads-url]: https://npmcharts.com/compare/@phantomstudios/css-components?minimal=true 358 | [ci-image]: https://github.com/phantomstudios/css-components/workflows/test/badge.svg 359 | [ci-url]: https://github.com/phantomstudios/css-components/actions 360 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # css-components Support 2 | 3 | For _questions_ on how to use `css-components` or what went wrong when you tried something, our primary resource is by opening a [GitHub Issue](https://github.com/phantomstudios/css-components/issues), where you can get help from developers. 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {jest.ProjectConfig} 3 | */ 4 | module.exports = { 5 | roots: ["/test"], 6 | transform: { 7 | "^.+\\.tsx?$": "ts-jest", 8 | }, 9 | setupFilesAfterEnv: ["@testing-library/jest-dom/extend-expect"], 10 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@phantomstudios/css-components", 3 | "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.4.0", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "homepage": "https://github.com/phantomstudios/css-components#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/phantomstudios/css-components.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/phantomstudios/css-components/issues" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "css", 18 | "styling", 19 | "components" 20 | ], 21 | "bin": { 22 | "css-components": "lib/cli/index.js" 23 | }, 24 | "scripts": { 25 | "build": "tsc && npm run build:shebang", 26 | "build:types": "tsc --emitDeclarationOnly", 27 | "build:shebang": "echo '#!/usr/bin/env node' | cat - ./lib/cli/index.js > temp && mv temp ./lib/cli/index.js", 28 | "prepublishOnly": "npm run build", 29 | "test": "jest --verbose", 30 | "test:watch": "jest --verbose --watch", 31 | "test:cli": "npm run build && node lib/cli/index.js --css \"test/**/*.{css,scss}\"", 32 | "test:cli-css": "npm run build && node lib/cli/index.js --css \"test/**/*.css\"", 33 | "test:cli-scss": "npm run build && node lib/cli/index.js --css \"test/**/*.scss\"", 34 | "coverage": "jest --coverage", 35 | "lint": "NODE_ENV=test npm-run-all --parallel lint:*", 36 | "lint:js": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", 37 | "lint:format": "prettier \"**/*.{md,html,yaml,yml}\" --check", 38 | "lint:type-check": "tsc --noEmit", 39 | "fix": "npm-run-all --sequential fix:*", 40 | "fix:js": "eslint \"src/**/*.{js,jsx,ts,tsx}\" --fix", 41 | "fix:format": "prettier \"**/*.{md,html,yaml,yml}\" --write", 42 | "depcheck": "npx npm-check --update" 43 | }, 44 | "author": "John Chipps-Harding (john.chipps-harding@phntms.com)", 45 | "license": "MIT", 46 | "peerDependencies": { 47 | "react": ">=16.8.0" 48 | }, 49 | "devDependencies": { 50 | "@babel/preset-env": "^7.20.2", 51 | "@babel/preset-typescript": "^7.18.6", 52 | "@testing-library/jest-dom": "^5.16.5", 53 | "@testing-library/react": "^13.4.0", 54 | "@types/glob": "^8.0.0", 55 | "@types/jest": "^29.2.3", 56 | "@types/react": "^18.0.25", 57 | "@typescript-eslint/eslint-plugin": "^5.43.0", 58 | "@typescript-eslint/parser": "^5.43.0", 59 | "eslint": "^8.27.0", 60 | "eslint-config-prettier": "^8.5.0", 61 | "eslint-plugin-import": "^2.26.0", 62 | "eslint-plugin-prettier": "^4.2.1", 63 | "eslint-plugin-react": "^7.31.10", 64 | "eslint-plugin-react-hooks": "^4.6.0", 65 | "expect-type": "^1.2.1", 66 | "jest": "^29.3.1", 67 | "jest-environment-jsdom": "^29.3.1", 68 | "npm-run-all": "^4.1.5", 69 | "prettier": "^2.7.1", 70 | "react": "^18.2.0", 71 | "react-dom": "^18.2.0", 72 | "ts-jest": "^29.0.3", 73 | "typescript": "^4.9.3" 74 | }, 75 | "dependencies": { 76 | "glob": "^8.0.3", 77 | "glob-promise": "^6.0.2", 78 | "sass": "^1.62.0", 79 | "yargs": "^17.6.2" 80 | } 81 | } -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | import yargs from "yargs/yargs"; 5 | 6 | import { 7 | extractStyles, 8 | findFiles, 9 | generateOutput, 10 | stylesToConfig, 11 | } from "./utils"; 12 | 13 | const argv = yargs(process.argv.slice(2)) 14 | .options({ 15 | css: { 16 | type: "string", 17 | describe: "path to css file, or glob pattern", 18 | demandOption: true, 19 | }, 20 | output: { 21 | type: "string", 22 | describe: "filename to save alongside the css file", 23 | default: "styles.ts", 24 | }, 25 | overwrite: { 26 | type: "boolean", 27 | describe: "should the output file be overwritten if it exists", 28 | default: false, 29 | }, 30 | }) 31 | .parseSync(); 32 | 33 | const cssFile = argv.css; 34 | const outputFileName = argv.output; 35 | const overwrite = argv.overwrite; 36 | 37 | findFiles(cssFile).then((files) => { 38 | files.forEach((file) => { 39 | const styles = extractStyles(file); 40 | const config = stylesToConfig(styles || []); 41 | const output = generateOutput(config, path.basename(file)); 42 | const folder = path.dirname(file); 43 | const outputPath = path.join(folder, outputFileName); 44 | const exists = fs.existsSync(outputPath); 45 | 46 | if (exists && !overwrite) { 47 | console.log(`File ${outputPath} already exists, skipping`); 48 | return; 49 | } 50 | 51 | fs.writeFileSync(outputPath, output); 52 | console.log( 53 | `${Object.keys(config).length} components written to: ${outputPath}` 54 | ); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/cli/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { extname } from "path"; 3 | 4 | import globTool from "glob-promise"; 5 | import sass from "sass"; 6 | 7 | type KeyValuePair = { [key: string]: string }; 8 | 9 | interface Config { 10 | [key: string]: { 11 | element: string; 12 | css: string; 13 | variants: { 14 | [key: string]: KeyValuePair; 15 | }; 16 | compoundVariants: KeyValuePair[]; 17 | }; 18 | } 19 | 20 | export const extractStyles = (path: string) => { 21 | const fileContent = 22 | extname(path) === ".scss" 23 | ? sass.compile(path).css 24 | : fs.readFileSync(path).toString(); 25 | 26 | return fileContent.match( 27 | /([a-zA-Z_]*)(?:[.]{1})([a-zA-Z_]+[\w\-_]*)(?:[\s\.\,\{\>#\:]{0})/gim 28 | ); 29 | }; 30 | 31 | export const stylesToConfig = (styles: string[]) => { 32 | const config: Config = {}; 33 | styles.forEach((item) => { 34 | const parts = item.split("."); 35 | const element = parts[0]; 36 | const className = parts[1]; 37 | const chunks = className.split("_"); 38 | 39 | if (chunks.length === 2) return; 40 | 41 | if (chunks.length >= 1) { 42 | const component = chunks[0]; 43 | 44 | if (!config[component]) { 45 | config[component] = { 46 | variants: {}, 47 | compoundVariants: [], 48 | css: component, 49 | element, 50 | }; 51 | } 52 | 53 | if (chunks.length === 3 || chunks.length === 4) { 54 | const variant = chunks[1]; 55 | const option = chunks[2]; 56 | if (!config[component].variants[variant]) { 57 | config[component].variants[variant] = {}; 58 | } 59 | config[component].variants[variant][option] = className; 60 | } else if (chunks.length > 4) { 61 | const variants = chunks.slice(1, chunks.length); 62 | 63 | const vars = variants.reduce((acc, cur, i, arr) => { 64 | if (i % 2 !== 0 || i + 1 >= arr.length) return acc; 65 | acc[cur] = arr[i + 1]; 66 | return acc; 67 | }, {} as KeyValuePair); 68 | 69 | config[component].compoundVariants.push({ 70 | ...vars, 71 | css: className, 72 | }); 73 | } 74 | } 75 | }); 76 | return config; 77 | }; 78 | 79 | export const generateOutput = (config: Config, cssFilename: string) => { 80 | let s = ""; 81 | s += `import { styled } from "@phantomstudios/css-components";\n\n`; 82 | s += `import css from "./${cssFilename}";\n\n`; 83 | 84 | Object.keys(config).forEach((key) => { 85 | const hasVariants = Object.keys(config[key].variants).length > 0; 86 | const hasCompoundVariants = config[key].compoundVariants.length > 0; 87 | const componentName = key.charAt(0).toUpperCase() + key.slice(1); 88 | s += `export const ${componentName} = styled("${config[key].element}", {\n`; 89 | 90 | s += ` css: css.${key},\n`; 91 | if (hasVariants) { 92 | s += ` variants: {\n`; 93 | Object.keys(config[key].variants).forEach((variant) => { 94 | s += ` ${variant}: {\n`; 95 | Object.keys(config[key].variants[variant]).forEach((option) => { 96 | s += ` ${option}: css.${config[key].variants[variant][option]},\n`; 97 | }); 98 | s += ` },\n`; 99 | }); 100 | s += ` },\n`; 101 | } 102 | 103 | if (hasCompoundVariants) { 104 | s += ` compoundVariants: [\n`; 105 | config[key].compoundVariants.forEach((variant) => { 106 | s += ` {\n`; 107 | Object.keys(variant).forEach((key) => { 108 | s += ` ${key}: css.${variant[key]},\n`; 109 | }); 110 | s += ` },\n`; 111 | }); 112 | s += ` ],\n`; 113 | } 114 | 115 | if (hasVariants) { 116 | s += ` defaultVariants: {\n`; 117 | Object.keys(config[key].variants).forEach((variant) => { 118 | Object.keys(config[key].variants[variant]).forEach((option) => { 119 | if (config[key].variants[variant][option].endsWith("default")) 120 | s += ` ${variant}: "${option}",\n`; 121 | }); 122 | }); 123 | s += ` },\n`; 124 | } 125 | 126 | s += `});\n`; 127 | }); 128 | 129 | return s; 130 | }; 131 | 132 | export const findFiles = (glob: string) => globTool(glob); 133 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createElement, forwardRef } from "react"; 2 | 3 | import { 4 | CSSComponentConfig, 5 | CSS, 6 | PolymorphicComponent, 7 | variantsType, 8 | } from "./type"; 9 | import { findMatchingCompoundVariants, flattenCss } from "./utils"; 10 | 11 | export { CSSComponentConfig, CSS, VariantProps } from "./type"; 12 | 13 | export const styled = < 14 | V extends variantsType | object, 15 | E extends React.ElementType 16 | >( 17 | element: E, 18 | config?: CSSComponentConfig 19 | ) => { 20 | const styledComponent = forwardRef( 21 | (props, ref) => { 22 | const mergedProps = { ...config?.defaultVariants, ...props } as { 23 | [key: string]: string; 24 | }; 25 | 26 | // Initialize variables to store the new props and styles 27 | const componentProps: { [key: string]: unknown } = {}; 28 | const componentStyles: string[] = []; 29 | 30 | // Pass through an existing className if it exists 31 | if (mergedProps.className) componentStyles.push(mergedProps.className); 32 | 33 | // Add the base style(s) 34 | if (config?.css) componentStyles.push(flattenCss(config.css)); 35 | 36 | // Pass through the ref 37 | if (ref) componentProps.ref = ref; 38 | 39 | Object.keys(mergedProps).forEach((key) => { 40 | // Apply any variant styles 41 | if (config?.variants && config.variants.hasOwnProperty(key)) { 42 | const variant = config.variants[key as keyof typeof config.variants]; 43 | if (variant && variant.hasOwnProperty(mergedProps[key])) { 44 | const selector = variant[ 45 | mergedProps[key] as keyof typeof variant 46 | ] as CSS; 47 | componentStyles.push(flattenCss(selector)); 48 | } 49 | } 50 | 51 | const isVariant = 52 | config?.variants && config.variants.hasOwnProperty(key); 53 | 54 | // Only pass through the prop if it's not a variant or been told to pass through 55 | if (isVariant && !config?.passthrough?.includes(key as keyof V)) return; 56 | 57 | componentProps[key] = mergedProps[key]; 58 | }); 59 | 60 | // Apply any compound variant styles 61 | if (config?.compoundVariants) { 62 | const matches = findMatchingCompoundVariants( 63 | config.compoundVariants, 64 | mergedProps 65 | ); 66 | 67 | matches.forEach((match) => { 68 | componentStyles.push(flattenCss(match.css as CSS)); 69 | }); 70 | } 71 | 72 | componentProps.className = componentStyles.join(" "); 73 | styledComponent.displayName = element.toString(); 74 | return createElement(element, componentProps); 75 | } 76 | ); 77 | 78 | return styledComponent as PolymorphicComponent; 79 | }; 80 | -------------------------------------------------------------------------------- /src/type.ts: -------------------------------------------------------------------------------- 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 15 | > = JSX.LibraryManagedAttributes>; 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, 24 | OverrideProps = Record 25 | > = OverrideProps & Omit; 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 35 | > = ExtendableProps, Props>; 36 | 37 | export type PolymorphicRef = 38 | React.ComponentPropsWithRef["ref"]; 39 | 40 | export type PolymorphicComponentProps< 41 | C extends React.ElementType, 42 | Props = Record 43 | > = InheritableElementProps; 44 | 45 | export type PolymorphicComponentPropsWithRef< 46 | C extends React.ElementType, 47 | Props = Record 48 | > = PolymorphicComponentProps & { ref?: PolymorphicRef }; 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>>; 59 | 60 | /** 61 | * The CSS Component Config type. 62 | */ 63 | export interface CSSComponentConfig { 64 | css?: CSS; 65 | variants?: V; 66 | compoundVariants?: CompoundVariantType[]; 67 | defaultVariants?: { 68 | [Property in keyof V]?: BooleanIfStringBoolean; 69 | }; 70 | passthrough?: (keyof V)[]; 71 | } 72 | 73 | /** 74 | * Allows you to extract a type for variant values. 75 | */ 76 | export type VariantProps< 77 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 78 | C extends keyof JSX.IntrinsicElements | JSXElementConstructor 79 | > = React.ComponentProps; 80 | 81 | /** 82 | * CSS can be passed in as either a string or an array of strings. 83 | */ 84 | export type CSS = 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]: CSS }; 93 | }>; 94 | 95 | /** 96 | * Returns a boolean type if a "true" or "false" string type is passed in. 97 | */ 98 | export type BooleanIfStringBoolean = 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 = { 106 | [Property in keyof V]?: BooleanIfStringBoolean; 107 | }; 108 | 109 | /** 110 | * Returns a type object for compound variants. 111 | */ 112 | export type CompoundVariantType = VariantOptions & { 113 | css: CSS; 114 | }; 115 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { CSS, 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: CSS) => 18 | Array.isArray(css) ? css.join(" ") : css; 19 | -------------------------------------------------------------------------------- /test/cli/css/test.css: -------------------------------------------------------------------------------- 1 | div.almostEmpty { 2 | color: orange; 3 | } 4 | 5 | footer.footer { 6 | background-color: #aaa; 7 | padding: 32px; 8 | } 9 | 10 | footer.footer_fixed_true { 11 | position: fixed; 12 | bottom: 0; 13 | left: 0; 14 | right: 0; 15 | } 16 | 17 | 18 | footer.footer_theme_light_default { 19 | background-color: white; 20 | } 21 | 22 | footer.footer_theme_light_default a { 23 | background-color: black; 24 | } 25 | 26 | footer.footer_theme_dark { 27 | background-color: black; 28 | } 29 | 30 | footer.footer_theme_dark a { 31 | background-color: white; 32 | } 33 | 34 | 35 | 36 | footer.footer_fixed_true_theme_light { 37 | color: red; 38 | } 39 | 40 | footer.footer_fixed_false_theme_light { 41 | color: red; 42 | } 43 | 44 | 45 | a.link { 46 | text-decoration: none; 47 | margin-right: 16px; 48 | } 49 | 50 | a.link:hover { 51 | color: #2244ff; 52 | } 53 | 54 | a.link_primary_true { 55 | font-size: 1.5em; 56 | } 57 | -------------------------------------------------------------------------------- /test/cli/scss/test.scss: -------------------------------------------------------------------------------- 1 | div.almostEmpty { 2 | color: orange; 3 | } 4 | 5 | footer.footer { 6 | background-color: #aaa; 7 | padding: 32px; 8 | 9 | &_fixed_true { 10 | position: fixed; 11 | bottom: 0; 12 | left: 0; 13 | right: 0; 14 | } 15 | 16 | &_theme_light_default { 17 | background-color: white; 18 | 19 | a { 20 | background-color: black; 21 | } 22 | } 23 | 24 | &_theme_dark { 25 | background-color: black; 26 | 27 | a { 28 | background-color: white; 29 | } 30 | } 31 | 32 | &_fixed_true_theme_light { 33 | color: red; 34 | } 35 | 36 | &_fixed_false_theme_light { 37 | color: red; 38 | } 39 | } 40 | 41 | a.link { 42 | text-decoration: none; 43 | margin-right: 16px; 44 | 45 | &:hover { 46 | color: #2244ff; 47 | } 48 | 49 | &_primary_true { 50 | font-size: 1.5em; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | 5 | import React from "react"; 6 | 7 | import { render } from "@testing-library/react"; 8 | import { expectTypeOf } from "expect-type"; 9 | 10 | import { VariantProps, styled } from "../src"; 11 | 12 | describe("Basic functionality", () => { 13 | it("should return the correct type of DOM node", async () => { 14 | const Button = styled("button"); 15 | const { container } = render(