├── .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();
16 | expect(container.firstChild?.nodeName).toEqual("BUTTON");
17 | });
18 |
19 | it("should apply the base class", async () => {
20 | const Button = styled("button", { css: "root" });
21 | const { container } = render();
22 | expect(container.firstChild).toHaveClass("root");
23 | });
24 |
25 | it("should pass through children", async () => {
26 | const Paragraph = styled("p");
27 | const { container } = render(Hello);
28 | expect(container.firstChild).toHaveTextContent("Hello");
29 | });
30 |
31 | it("should pass through classNames", async () => {
32 | const Paragraph = styled("p", { css: "paragraph" });
33 | const { container } = render(
34 | Hello
35 | );
36 | expect(container.firstChild).toHaveClass("paragraph");
37 | expect(container.firstChild).toHaveClass("summary");
38 | });
39 |
40 | it("should pass through classNames for composed css-components", async () => {
41 | const BaseParagraph = styled("p", { css: "baseParagraph" });
42 | const Paragraph = styled(BaseParagraph, { css: "paragraph" });
43 | const { container } = render(Hello);
44 |
45 | expect(container.firstChild).toHaveClass("baseParagraph");
46 | expect(container.firstChild).toHaveClass("paragraph");
47 | });
48 |
49 | it("should pass through multiple children", async () => {
50 | const Article = styled("article");
51 | const { container } = render(
52 |
53 |