├── .eslintignore ├── .eslintrc.json ├── .github └── pull_request_template.md ├── .gitignore ├── .prettierrc ├── .yarnrc.yml ├── LICENSE ├── README.md ├── docs ├── mdx-components.js ├── next-env.d.ts ├── next.config.mjs ├── package.json ├── src │ ├── app │ │ ├── GlobalStylings.tsx │ │ ├── _meta.ts │ │ ├── docs │ │ │ └── [[...mdxPath]] │ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── opengraph-image.png │ │ └── page.tsx │ └── content │ │ ├── _meta.ts │ │ ├── api.mdx │ │ ├── colors.mdx │ │ ├── conventions.mdx │ │ ├── custom-composers.mdx │ │ ├── frame.mdx │ │ ├── index.mdx │ │ ├── inline-components.mdx │ │ ├── performance.mdx │ │ ├── reusing-styles.mdx │ │ ├── themes.mdx │ │ └── using-styles.mdx └── tsconfig.json ├── package.json ├── stylings ├── .npmignore ├── README.md ├── demo │ ├── Demo.tsx │ ├── app.tsx │ ├── dev.ts │ ├── index.html │ ├── index.tsx │ ├── perf.tsx │ └── playground.tsx ├── package.json ├── src │ ├── AnimationComposer.ts │ ├── ColorComposer.ts │ ├── CommonComposer.ts │ ├── Composer.ts │ ├── ComposerConfig.ts │ ├── FlexComposer.ts │ ├── FontComposer.ts │ ├── GridComposer.ts │ ├── ShadowComposer.ts │ ├── SizeComposer.ts │ ├── SurfaceComposer.ts │ ├── ThemeProvider.ts │ ├── ThemedValue.ts │ ├── TransitionComposer.ts │ ├── UI.tsx │ ├── compilation.ts │ ├── defaults.ts │ ├── index.ts │ ├── input.ts │ ├── theme.ts │ ├── themeHooks.ts │ ├── types.ts │ ├── utils.ts │ └── utils │ │ ├── array.ts │ │ ├── assert.ts │ │ ├── color.ts │ │ ├── convertUnits.ts │ │ ├── debug.ts │ │ ├── env.ts │ │ ├── hooks.ts │ │ ├── id.ts │ │ ├── json.ts │ │ ├── map │ │ ├── DeepMap.tsx │ │ ├── HashMap.tsx │ │ └── MaybeWeakMap.ts │ │ ├── maybeArray.ts │ │ ├── maybeValue.ts │ │ ├── memoize.ts │ │ ├── nestedRecord.ts │ │ ├── nullish.ts │ │ ├── objectHash.ts │ │ ├── objectId.ts │ │ ├── primitive.ts │ │ ├── react │ │ └── internals.ts │ │ ├── registry.ts │ │ ├── reuse.ts │ │ └── updateValue.ts ├── tests │ ├── animation.spec.ts │ ├── flex.spec.ts │ ├── hash.spec.ts │ └── theme.spec.ts ├── tsconfig.build.json ├── tsconfig.json ├── vite.config.build.ts ├── vite.config.demo.ts └── vitest.config.ts ├── vercel.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .prettierrc.ts 4 | .eslintrc.json 5 | env.d.ts 6 | vite.config.ts -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | // By extending from a plugin config, we can get recommended rules without having to add them manually. 4 | "eslint:recommended", 5 | "plugin:import/recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | // This disables the formatting rules in ESLint that Prettier is going to be responsible for handling. 8 | // Make sure it's always the last config, so it gets the chance to override other configs. 9 | "eslint-config-prettier" 10 | ], 11 | 12 | "settings": { 13 | // Tells eslint how to resolve imports 14 | "import/resolver": { 15 | "node": { 16 | "paths": ["src"], 17 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 18 | } 19 | } 20 | }, 21 | "parserOptions": { 22 | "sourceType": "module" 23 | }, 24 | "rules": { 25 | "no-console" : 2 26 | } 27 | } -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull request template 2 | 3 | ***REMOVE THIS - START*** 4 | 5 | 1. Title should be something descriptive about what you're changes do. (It will default to whatever you put as your commit message.) 6 | 2. Make sure to point to `dev` branch; 7 | 3. Mark your pull request as `DRAFT` until it will be ready to review; 8 | 4. Before marking a PR ready to review, make sure that: 9 | 10 | a. You have done your changes in a separate branch. Branches MUST have descriptive names that start with either the `fix/` or `feature/` prefixes. Good examples are: `fix/signin-issue` or `feature/issue-templates`. 11 | 12 | b. `npm test` doesn't throw any error. 13 | 14 | 5. Describe your changes, link to the issue and add images if relevant under each #TODO next comments; 15 | 6. MAKE SURE TO CLEAN ALL THIS 6 POINTS BEFORE SUBMITTING 16 | 17 | ***REMOVE THIS - END*** 18 | 19 | ## Describe your changes 20 | TODO: Add a short summary of your changes and impact: 21 | 22 | ## Link to issue this resolves 23 | TODO: Add your issue no. or link to the issue. Example: 24 | Fixes: #100 25 | 26 | ## Screenshot of changes(if relevant) 27 | TODO: Add images: 28 | 29 | 🙏🙏 !! THANK YOU !! 🚀🚀 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | .yarn/* 27 | 28 | .next/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": false, 6 | "printWidth": 120, 7 | "bracketSpacing": true 8 | } 9 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Adam Pietrasiak 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 | # stylings 2 | 3 | `stylings` is an opinionated and joyful library that helps you write semantic, composable, reusable styles using `React` and `styled-components`. 4 | 5 | It is battle-tested and used in production at [Screen Studio](https://screen.studio). 6 | 7 | Docs - [stylings.dev](https://stylings.dev). 8 | 9 | ## Goals & Motivation 10 | 11 | - Joyful and productive developer experience 12 | - Easy to maintain a consistent design language 13 | - Allows rapid iterations over UI code 14 | - Semantic styling - i.e., code should not only describe how something looks, but also what it is 15 | - Good debugging experience in devtools 16 | - Makes opinionated and automated choices on common styling patterns 17 | - Simple, chainable, composable API that is enjoyable to use 18 | - Automate some styling tasks (e.g., generate hover color variants) 19 | 20 | ## Installation 21 | 22 | ```sh 23 | npm i stylings 24 | # or 25 | yarn add stylings 26 | ``` 27 | 28 | ## Quick Start 29 | 30 | ### Compose your first styles 31 | 32 | ```tsx 33 | import { $flex, $animation } from "stylings"; 34 | 35 | // Compose styles using chainable API 36 | $flex.horizontal.alignCenter.gap(2); 37 | 38 | // Create reusable styles and organize them how you like 39 | const animations = { 40 | $fadeIn: $animation.properties({ opacity: [0, 1] }).duration("100ms").easeInOut, 41 | }; 42 | ``` 43 | 44 | ### Use with styled-components 45 | 46 | ```tsx 47 | import styled from "styled-components"; 48 | import { $flex } from "stylings"; 49 | import { animations } from "./styles"; 50 | 51 | const Intro = styled.div` 52 | ${animations.$fadeIn}; 53 | ${$flex.horizontal.alignCenter.gap(2)}; 54 | `; 55 | 56 | function App() { 57 | return Hello World; 58 | } 59 | ``` 60 | 61 | ### Use with inline components 62 | 63 | ```tsx 64 | import { UI, $flex } from "stylings"; 65 | import { animations } from "./styles"; 66 | 67 | function App() { 68 | return Hello World; 69 | } 70 | ``` 71 | 72 | ## Core Features 73 | 74 | ### Style Composers 75 | 76 | At the core of `stylings` are style composers. These are objects that collect desired styles and can be used to generate CSS. The main built-in composers are: 77 | 78 | - `$flex` - for flexbox layouts 79 | - `$grid` - for grid layouts 80 | - `$animation` - for animations 81 | - `$colors` - for color management 82 | - `$frame` - for consistent UI element shapes 83 | - `$font` - for typography 84 | - `$shadow` - for box shadows 85 | - `$common` - for common styles 86 | - `$transition` - for transitions 87 | 88 | ### Working with Colors 89 | 90 | ```tsx 91 | import { $color } from "stylings"; 92 | 93 | // Define a color 94 | const $primary = $color("#048"); 95 | 96 | // Use it in styles 97 | const Button = styled.button` 98 | ${$primary.interactive.asBg}; // Interactive adds hover states 99 | `; 100 | ``` 101 | 102 | ### Frame System 103 | 104 | The Frame system helps maintain consistent sizing and spacing across UI elements: 105 | 106 | ```tsx 107 | import { $frame } from "stylings"; 108 | 109 | const $control = $frame({ 110 | height: 8, 111 | paddingX: 2, 112 | paddingY: 1, 113 | radius: 1, 114 | }); 115 | 116 | const Button = styled.button` 117 | ${$control.minHeight.paddingX.radius}; 118 | ${$flex.center}; 119 | `; 120 | ``` 121 | 122 | ### Theming System 123 | 124 | ```tsx 125 | import { createTheme, color, font } from "stylings"; 126 | 127 | const baseFont = font.family("Inter, sans-serif").lineHeight(1.5).antialiased; 128 | 129 | const theme = createTheme({ 130 | spacing: 16, 131 | typo: { 132 | base: baseFont, 133 | header: baseFont.size("2rem").bold, 134 | }, 135 | colors: { 136 | primary: color({ color: "red" }), 137 | text: color({ color: "black" }), 138 | background: color({ color: "white" }), 139 | }, 140 | }); 141 | 142 | // Create theme variants 143 | const darkTheme = createThemeVariant(theme, { 144 | colors: { 145 | text: color({ color: "white" }), 146 | background: color({ color: "black" }), 147 | }, 148 | }); 149 | ``` 150 | 151 | ### Custom Composers 152 | 153 | You can create your own composable styles: 154 | 155 | ```tsx 156 | import { Composer, composer } from "stylings"; 157 | 158 | export class DropDownStylesComposer extends Composer { 159 | get shadow() { 160 | return this.addStyle({ boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.1)" }); 161 | } 162 | 163 | get base() { 164 | return this.addStyle({ 165 | padding: "1rem", 166 | borderRadius: "0.5rem", 167 | }); 168 | } 169 | 170 | // ... more styles 171 | 172 | get all() { 173 | return this.shadow.base.border.background; 174 | } 175 | } 176 | 177 | export const $dropdown = composer(DropDownStylesComposer); 178 | ``` 179 | 180 | ## License 181 | 182 | MIT License 183 | 184 | Copyright (c) 2024 Adam Pietrasiak 185 | 186 | Permission is hereby granted, free of charge, to any person obtaining a copy 187 | of this software and associated documentation files (the "Software"), to deal 188 | in the Software without restriction, including without limitation the rights 189 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 190 | copies of the Software, and to permit persons to whom the Software is 191 | furnished to do so, subject to the following conditions: 192 | 193 | The above copyright notice and this permission notice shall be included in all 194 | copies or substantial portions of the Software. 195 | 196 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 197 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 198 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 199 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 200 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 201 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 202 | SOFTWARE. 203 | -------------------------------------------------------------------------------- /docs/mdx-components.js: -------------------------------------------------------------------------------- 1 | import { useMDXComponents as getDocsMDXComponents } from 'nextra-theme-docs' 2 | 3 | const docsComponents = getDocsMDXComponents() 4 | 5 | export const useMDXComponents = components => ({ 6 | ...docsComponents, 7 | ...components 8 | }) 9 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /docs/next.config.mjs: -------------------------------------------------------------------------------- 1 | import nextra from "nextra"; 2 | 3 | const withNextra = nextra({ 4 | latex: true, 5 | search: { 6 | codeblocks: false, 7 | }, 8 | contentDirBasePath: "/docs", 9 | }); 10 | 11 | export default withNextra({ 12 | reactStrictMode: true, 13 | typescript: { 14 | ignoreBuildErrors: true, 15 | }, 16 | eslint: { 17 | ignoreDuringBuilds: true, 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stylings-docs", 3 | "license": "MIT", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next --turbopack", 8 | "postbuild": "pagefind --site .next/server/app --output-path public/_pagefind", 9 | "start": "next start" 10 | }, 11 | "dependencies": { 12 | "next": "^15.0.2", 13 | "nextra": "^4.2.17", 14 | "nextra-theme-docs": "^4.2.17", 15 | "react": "18.3.1", 16 | "react-dom": "18.3.1" 17 | }, 18 | "devDependencies": { 19 | "pagefind": "^1.3.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/src/app/GlobalStylings.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | 5 | export function GlobalStylings() { 6 | useEffect(() => { 7 | // Reflect.set(window, "stylings", stylings); 8 | }); 9 | return null; 10 | } 11 | -------------------------------------------------------------------------------- /docs/src/app/_meta.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | index: { 3 | display: "hidden", 4 | }, 5 | docs: { 6 | type: "page", 7 | title: "Documentation", 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /docs/src/app/docs/[[...mdxPath]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { generateStaticParamsFor, importPage } from 'nextra/pages' 2 | import { useMDXComponents as getMDXComponents } from '../../../../mdx-components' 3 | 4 | export const generateStaticParams = generateStaticParamsFor('mdxPath') 5 | 6 | export async function generateMetadata(props) { 7 | const params = await props.params 8 | const { metadata } = await importPage(params.mdxPath) 9 | return metadata 10 | } 11 | 12 | const Wrapper = getMDXComponents().wrapper 13 | 14 | export default async function Page(props) { 15 | const params = await props.params 16 | const result = await importPage(params.mdxPath) 17 | const { default: MDXContent, toc, metadata } = result 18 | return ( 19 | 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /docs/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "nextra-theme-docs/style.css"; 2 | 3 | import { Banner, Head } from "nextra/components"; 4 | /* eslint-env node */ 5 | import { Footer, Layout, Navbar } from "nextra-theme-docs"; 6 | 7 | import { GlobalStylings } from "./GlobalStylings"; 8 | import { getPageMap } from "nextra/page-map"; 9 | 10 | export const metadata = { 11 | metadataBase: new URL("https://stylings.dev"), 12 | title: { 13 | template: "%s - stylings", 14 | }, 15 | description: "stylings: joyful styling for React and Styled Components", 16 | applicationName: "stylings", 17 | generator: "Next.js", 18 | appleWebApp: { 19 | title: "stylings", 20 | }, 21 | // other: { 22 | // "msapplication-TileImage": "/ms-icon-144x144.png", 23 | // "msapplication-TileColor": "#fff", 24 | // }, 25 | twitter: { 26 | site: "https://stylings.dev", 27 | card: "summary_large_image", 28 | }, 29 | openGraph: { 30 | type: "website", 31 | url: "https://stylings.dev", 32 | title: "stylings", 33 | description: "stylings: joyful styling for React and Styled Components", 34 | images: [ 35 | { 36 | url: "https://stylings.dev/opengraph-image.png", 37 | width: 1200, 38 | height: 630, 39 | alt: "stylings", 40 | }, 41 | ], 42 | }, 43 | }; 44 | 45 | export default async function RootLayout({ children }) { 46 | const navbar = ( 47 | 50 | stylings 51 | 52 | } 53 | projectLink="https://github.com/pie6k/stylings" 54 | /> 55 | ); 56 | const pageMap = await getPageMap(); 57 | return ( 58 | 59 | 60 | 61 | Nextra 2 Alpha} 63 | navbar={navbar} 64 | footer={
MIT {new Date().getFullYear()} © stylings.
} 65 | editLink="Edit this page on GitHub" 66 | docsRepositoryBase="https://github.com/pie6k/stylings/blob/main/docs" 67 | sidebar={{ defaultMenuCollapseLevel: 1 }} 68 | pageMap={pageMap} 69 | > 70 | 71 | {children} 72 |
73 | 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /docs/src/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pie6k/stylings/c03f47213a1cd94ab42fe6f63fb92618c34d5589/docs/src/app/opengraph-image.png -------------------------------------------------------------------------------- /docs/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | export default async function IndexPage() { 4 | redirect("/docs"); 5 | } 6 | -------------------------------------------------------------------------------- /docs/src/content/_meta.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | index: "", 3 | "using-styles": "Using Styles", 4 | "reusing-styles": "Reusing Styles", 5 | "inline-components": "Inline Components", 6 | colors: "Colors", 7 | frame: "Frame", 8 | themes: "Themes", 9 | "custom-composers": "Custom Composers", 10 | conventions: "Conventions", 11 | api: "API", 12 | performance: "Performance", 13 | }; 14 | -------------------------------------------------------------------------------- /docs/src/content/api.mdx: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## Core Composers 4 | 5 | ### Animation Composer (`$animation`) 6 | 7 | ```typescript 8 | $animation 9 | .property(property, steps) // Animate a specific property 10 | .duration(duration) // Set animation duration 11 | .delay(delay) // Set animation delay 12 | .easing(easing) // Set animation timing function 13 | .fillMode(mode) // Set animation fill mode 14 | .iterationCount(count) // Set animation iteration count 15 | ``` 16 | 17 | #### Preset Animations 18 | - `fadeIn` - Fade in animation 19 | - `fadeOut` - Fade out animation 20 | - `slideUpFromBottom(by)` - Slide up from bottom 21 | - `slideDownFromTop(by)` - Slide down from top 22 | - `slideLeftFromRight(by)` - Slide left from right 23 | - `slideRightFromLeft(by)` - Slide right from left 24 | - `zoomIn(scale)` - Zoom in animation 25 | - `zoomOut(scale)` - Zoom out animation 26 | - `spin` - Continuous rotation animation 27 | - `infinite` - Make animation loop infinitely 28 | 29 | #### Property Animations 30 | - `rotate(angles)` - Animate rotation 31 | - `x(steps)` - Animate X position 32 | - `y(steps)` - Animate Y position 33 | - `scale(steps)` - Animate scale 34 | - `blur(steps)` - Animate blur 35 | - `opacity(steps)` - Animate opacity 36 | 37 | ### Color Composer (`$color`) 38 | 39 | ```typescript 40 | $color(color) 41 | .define(value) // Define color configuration 42 | .color(value) // Set color value 43 | .opacity(value) // Set opacity 44 | .outputType(value) // Set output type (inline/background/color/border/outline/fill) 45 | ``` 46 | 47 | #### Color Modifiers 48 | - `secondary` - Use secondary color 49 | - `tertiary` - Use tertiary color 50 | - `transparent` - Make color transparent 51 | - `interactive` - Enable interactive states 52 | - `hover` - Apply hover state 53 | - `active` - Apply active state 54 | - `muted` - Apply muted state 55 | - `highlight(ratio)` - Adjust color brightness 56 | - `foreground` - Use as foreground color 57 | 58 | #### Output Types 59 | - `asBg` - Use as background 60 | - `asColor` - Use as text color 61 | - `asBorder` - Use as border color 62 | - `asOutline` - Use as outline color 63 | - `asFill` - Use as fill color 64 | - `withBorder` - Add border 65 | 66 | ### Common Composer (`$common`) 67 | 68 | #### Layout 69 | - `width(width)` - Set width 70 | - `height(height)` - Set height 71 | - `size(size)` - Set both width and height 72 | - `fullWidth` - Set width to 100% 73 | - `fullHeight` - Set height to 100% 74 | - `square` - Make element square 75 | - `circle` - Make element circular 76 | - `aspectRatio(ratio)` - Set aspect ratio 77 | 78 | #### Positioning 79 | - `relative` - Set position relative 80 | - `absolute` - Set position absolute 81 | - `fixed` - Set position fixed 82 | - `z(z)` - Set z-index 83 | - `left(left)` - Set left position 84 | - `right(right)` - Set right position 85 | - `top(top)` - Set top position 86 | - `bottom(bottom)` - Set bottom position 87 | - `inset(inset)` - Set all positions 88 | 89 | #### Spacing 90 | - `p(value)` - Set padding on all sides 91 | - `px(value)` - Set horizontal padding 92 | - `py(value)` - Set vertical padding 93 | - `pt(value)` - Set top padding 94 | - `pb(value)` - Set bottom padding 95 | - `pl(value)` - Set left padding 96 | - `pr(value)` - Set right padding 97 | - `m(value)` - Set margin on all sides 98 | - `mx(value)` - Set horizontal margin 99 | - `my(value)` - Set vertical margin 100 | - `mt(value)` - Set top margin 101 | - `mb(value)` - Set bottom margin 102 | - `ml(value)` - Set left margin 103 | - `mr(value)` - Set right margin 104 | 105 | #### Other 106 | - `disabled` - Apply disabled state 107 | - `round` - Add border radius 108 | - `notSelectable` - Prevent text selection 109 | - `cursor(cursor)` - Set cursor style 110 | - `overflowHidden` - Hide overflow 111 | - `lineClamp(lines)` - Limit text to number of lines 112 | 113 | ### Flex Composer (`$flex`) 114 | 115 | ```typescript 116 | $flex 117 | .direction(direction) // Set flex direction 118 | .wrap(wrap) // Set flex wrap 119 | .justify(justify) // Set justify content 120 | .align(align) // Set align items 121 | .gap(gap) // Set gap between items 122 | .order(order) // Set order 123 | .grow(grow) // Set flex grow 124 | .shrink(shrink) // Set flex shrink 125 | .basis(basis) // Set flex basis 126 | ``` 127 | 128 | #### Direction 129 | - `row` - Set flex direction to row 130 | - `column` - Set flex direction to column 131 | - `horizontal` - Alias for row 132 | - `vertical` - Alias for column 133 | - `x` - Alias for row 134 | - `y` - Alias for column 135 | - `reverse` - Reverse flex direction 136 | 137 | #### Alignment 138 | - `alignCenter` - Center items vertically 139 | - `alignStart` - Align items to start 140 | - `alignEnd` - Align items to end 141 | - `alignStretch` - Stretch items vertically 142 | - `alignBaseline` - Align items to baseline 143 | 144 | #### Justification 145 | - `justifyCenter` - Center items horizontally 146 | - `justifyStart` - Justify items to start 147 | - `justifyEnd` - Justify items to end 148 | - `justifyBetween` - Space items between 149 | - `justifyAround` - Space items around 150 | - `justifyEvenly` - Space items evenly 151 | 152 | #### Other 153 | - `center` - Center items both vertically and horizontally 154 | - `wrap` - Enable flex wrap 155 | - `inline` - Make flex container inline 156 | 157 | ### Font Composer (`$font`) 158 | 159 | ```typescript 160 | $font 161 | .family(family) // Set font family 162 | .size(size) // Set font size 163 | .weight(weight) // Set font weight 164 | .style(style) // Set font style 165 | .lineHeight(height) // Set line height 166 | .letterSpacing(spacing) // Set letter spacing 167 | .align(align) // Set text align 168 | .decoration(decoration) // Set text decoration 169 | .transform(transform) // Set text transform 170 | .whitespace(whitespace) // Set whitespace handling 171 | ``` 172 | 173 | #### Line Height 174 | - `copyLineHeight` - Set line height for copy text 175 | - `headingLineHeight` - Set line height for headings 176 | - `balance` - Balance text lines 177 | - `resetLineHeight` - Reset line height 178 | 179 | #### Text Transform 180 | - `uppercase` - Transform text to uppercase 181 | - `lowercase` - Transform text to lowercase 182 | - `capitalize` - Capitalize text 183 | 184 | #### Text Decoration 185 | - `underline` - Add underline 186 | 187 | #### Text Alignment 188 | - `left` - Align text left 189 | - `center` - Align text center 190 | - `right` - Align text right 191 | 192 | #### Font Weight 193 | - `w100` through `w900` - Set font weight (100-900) 194 | - `normal` - Set normal font weight 195 | 196 | #### Other 197 | - `ellipsis` - Add text ellipsis 198 | - `nowrap` - Prevent text wrapping 199 | - `antialiased` - Enable font smoothing 200 | - `secondary` - Use secondary font 201 | - `tertiary` - Use tertiary font 202 | 203 | ### Grid Composer (`$grid`) 204 | 205 | ```typescript 206 | $grid 207 | .template(template) // Set grid template 208 | .columns(columns) // Set grid columns 209 | .rows(rows) // Set grid rows 210 | .gap(gap) // Set grid gap 211 | .area(area) // Set grid area 212 | .column(column) // Set grid column 213 | .row(row) // Set grid row 214 | .start(start) // Set grid start 215 | .end(end) // Set grid end 216 | .span(span) // Set grid span 217 | ``` 218 | 219 | #### Alignment 220 | - `alignItemsCenter` - Center items vertically 221 | - `alignItemsStart` - Align items to start 222 | - `alignItemsEnd` - Align items to end 223 | - `alignItemsStretch` - Stretch items vertically 224 | 225 | #### Justification 226 | - `justifyItemsCenter` - Center items horizontally 227 | - `justifyItemsStart` - Justify items to start 228 | - `justifyItemsEnd` - Justify items to end 229 | - `justifyItemsStretch` - Stretch items horizontally 230 | 231 | #### Content Alignment 232 | - `alignContentCenter` - Center content 233 | - `alignContentStart` - Align content to start 234 | - `alignContentEnd` - Align content to end 235 | - `alignContentStretch` - Stretch content 236 | - `alignContentBetween` - Space content between 237 | - `alignContentAround` - Space content around 238 | - `alignContentEvenly` - Space content evenly 239 | 240 | #### Flow 241 | - `flowRow` - Set grid auto-flow to row 242 | - `flowColumn` - Set grid auto-flow to column 243 | - `flowRowDense` - Set grid auto-flow to row dense 244 | - `flowColumnDense` - Set grid auto-flow to column dense 245 | 246 | #### Other 247 | - `center` - Center grid items 248 | - `inline` - Make grid container inline 249 | 250 | ### Shadow Composer (`$shadow`) 251 | 252 | ```typescript 253 | $shadow 254 | .define(value) // Define shadow configuration 255 | .color(color) // Set shadow color 256 | .blur(blur) // Set shadow blur 257 | .spread(spread) // Set shadow spread 258 | .offset(offset) // Set shadow offset 259 | .inset(value) // Set shadow inset 260 | .opacity(value) // Set shadow opacity 261 | ``` 262 | 263 | ### Size Composer (`$size`) 264 | 265 | ```typescript 266 | $size 267 | .base(value) // Set base size 268 | .rem(value) // Set size in rem units 269 | .level(level) // Set size level 270 | .px(value) // Set size in pixels 271 | .em(value) // Set size in em units 272 | ``` 273 | 274 | #### Size Properties 275 | - `width` - Set width 276 | - `height` - Set height 277 | - `size` - Set both width and height 278 | - `minSize` - Set minimum size 279 | - `maxSize` - Set maximum size 280 | - `minWidth` - Set minimum width 281 | - `maxWidth` - Set maximum width 282 | - `minHeight` - Set minimum height 283 | - `maxHeight` - Set maximum height 284 | 285 | #### Spacing Properties 286 | - `marginX` - Set horizontal margin 287 | - `marginY` - Set vertical margin 288 | - `marginTop` - Set top margin 289 | - `marginBottom` - Set bottom margin 290 | - `marginLeft` - Set left margin 291 | - `marginRight` - Set right margin 292 | - `margin` - Set all margins 293 | - `paddingX` - Set horizontal padding 294 | - `paddingY` - Set vertical padding 295 | - `paddingTop` - Set top padding 296 | - `paddingBottom` - Set bottom padding 297 | - `paddingLeft` - Set left padding 298 | - `paddingRight` - Set right padding 299 | - `padding` - Set all padding 300 | - `gap` - Set gap 301 | 302 | #### Transform Properties 303 | - `transformX` - Set X transform 304 | - `transformY` - Set Y transform 305 | - `transformXY` - Set both X and Y transform 306 | 307 | ### Surface Composer (`$surface`) 308 | 309 | ```typescript 310 | $surface(config) 311 | .padding(padding) // Set padding 312 | .radius(radius) // Set border radius 313 | .shadow(shadow) // Set box shadow 314 | ``` 315 | 316 | #### Properties 317 | - `padding` - Set padding 318 | - `paddingX` - Set horizontal padding 319 | - `paddingY` - Set vertical padding 320 | - `radius` - Set border radius 321 | - `height` - Set height 322 | - `width` - Set width 323 | - `circle` - Make surface circular 324 | - `size` - Set both width and height 325 | 326 | #### Padding Removal 327 | - `noPT` - Remove top padding 328 | - `noPB` - Remove bottom padding 329 | - `noPL` - Remove left padding 330 | - `noPR` - Remove right padding 331 | 332 | ### Transition Composer (`$transition`) 333 | 334 | ```typescript 335 | $transition 336 | .property(property) // Set transition property 337 | .duration(duration) // Set transition duration 338 | .delay(delay) // Set transition delay 339 | .easing(easing) // Set transition timing function 340 | ``` 341 | 342 | #### Preset Transitions 343 | - `slowRelease` - Slow release transition 344 | - `all` - Transition all properties 345 | - `colors` - Transition color properties 346 | - `common` - Common transition properties 347 | 348 | ## Theme System 349 | 350 | ### Theme Creation 351 | 352 | ```typescript 353 | createTheme(themeInput: T): ComposableTheme 354 | ``` 355 | 356 | ### Theme Variants 357 | 358 | ```typescript 359 | createThemeVariant( 360 | sourceTheme: ComposableTheme, 361 | variantInput: ThemeVariantInputObject 362 | ): ThemeVariant 363 | ``` 364 | 365 | ### Theme Composition 366 | 367 | ```typescript 368 | composeThemeVariants( 369 | sourceTheme: ComposableTheme, 370 | variants: ThemeVariant[] 371 | ): ThemeVariant 372 | ``` 373 | 374 | ### Themed Values 375 | 376 | ```typescript 377 | createThemedValue( 378 | path: string, 379 | defaultValue: V 380 | ): ThemedValue 381 | ``` 382 | 383 | ## Utility Functions 384 | 385 | ### Style Resolution 386 | 387 | ```typescript 388 | resolveStylesInput(styles?: StylesInput): Array 389 | ``` 390 | 391 | ### Theme Hooks 392 | 393 | ```typescript 394 | useThemeValue(themedValue: ThemedValueGetter): T 395 | ``` 396 | 397 | ## Built-in Components 398 | 399 | The library provides a comprehensive set of styled HTML and SVG components through the `UI` export: 400 | 401 | ```typescript 402 | import { UI } from 'stylings' 403 | 404 | // HTML Elements 405 | ... 406 | ... 407 | 408 | ... 409 | 410 | // SVG Elements 411 | ... 412 | 413 | 414 | ``` 415 | 416 | All components support the following props: 417 | - `as` - Change the underlying HTML element 418 | - `styles` - Apply styles using composers -------------------------------------------------------------------------------- /docs/src/content/colors.mdx: -------------------------------------------------------------------------------- 1 | 2 | # Working with colors 3 | 4 | `stylings` provides a `$color` composer that can be used to work with colors. 5 | 6 | ## Defining and using colors 7 | 8 | First, we need to define a color. 9 | 10 | ```tsx filename="colors.ts" {3} 11 | import { $color } from 'stylings'; 12 | 13 | const $primary = $color("#048"); 14 | ``` 15 | 16 | Then, we can use it in our styles. 17 | 18 | ```tsx filename="Button.tsx" /asBg/ /$primary/ 19 | import styled from 'styled-components'; 20 | import { $primary } from './colors'; 21 | 22 | const Button = styled.button` 23 | ${$primary.asBg}; 24 | `; 25 | ``` 26 | 27 | We are now using `$primary` color as a background color of the button. 28 | 29 | You can also use `.asColor`, `.asFill`, etc. You can use multiple of those together eg `$primary.asBg.asFill`. 30 | 31 | ## Dynamic colors 32 | 33 | In the example above, background of the button is static, even if button is hovered, focused or clicked. 34 | 35 | We can make it dynamic by using `.interactive` modifier. 36 | 37 | ```tsx filename="Button.tsx" /interactive/ 38 | const Button = styled.button` 39 | ${$primary.interactive.asBg}; 40 | `; 41 | ``` 42 | 43 | ## Using color value directly 44 | 45 | When using `.asBg`, `.asColor`, etc, `stylings` will automatically generate ready-to-use CSS styles. 46 | 47 | Sometimes, we might want to use color value directly in our styles. 48 | 49 | We can do that by passing color without using those modifiers. 50 | 51 | ```tsx filename="Button.tsx" /asColor/ 52 | const Button = styled.button` 53 | color: ${$primary}; 54 | border-color: ${$primary}; 55 | --some-variable: ${$primary}; 56 | `; 57 | ``` 58 | 59 | ## Modifying colors 60 | 61 | If needed, we can modify and adjust the color, using modifiers like `.opacity()`, `.lighten()`, `.darken()`, etc. 62 | 63 | ```tsx filename="Button.tsx" /modify/ 64 | const SubtleLink = styled.a` 65 | ${$primary.opacity(0.5).asColor}; 66 | `; 67 | ``` 68 | -------------------------------------------------------------------------------- /docs/src/content/conventions.mdx: -------------------------------------------------------------------------------- 1 | # Conventions 2 | 3 | ## Gaps 4 | 5 | `.gap(level)` on `$flex` and `$grid` uses gap levels, which grow exponentially, not linearly. 6 | 7 | Formula is `Math.pow(2, level) * 0.25rem`. 8 | 9 | ```tsx 10 | $flex.gap(1); // 0.5rem 11 | $flex.gap(2); // 1rem 12 | $flex.gap(3); // 2rem 13 | $flex.gap(4); // 4rem 14 | ``` 15 | 16 | -------------------------------------------------------------------------------- /docs/src/content/custom-composers.mdx: -------------------------------------------------------------------------------- 1 | # Custom Composers 2 | 3 | You can create your own composable styles by extending the `Composer` class. 4 | 5 | Let's say you want to create a custom `DropDownStylesComposer` composer that will be used to style dropdowns. 6 | 7 | ```ts 8 | import { Composer, composer } from "stylings"; 9 | 10 | export class DropDownStylesComposer extends Composer { 11 | get shadow() { 12 | return this.addStyle({ boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.1)" }); 13 | } 14 | 15 | get base() { 16 | return this.addStyle({ 17 | padding: "1rem", 18 | borderRadius: "0.5rem", 19 | }); 20 | } 21 | 22 | get border() { 23 | return this.addStyle({ 24 | border: "1px solid #e0e0e0", 25 | }); 26 | } 27 | 28 | get background() { 29 | return this.addStyle({ 30 | background: "white", 31 | }); 32 | 33 | // or 34 | return this.addStyle(theme.colors.dropdown.asBg); 35 | } 36 | 37 | get all() { 38 | return this.shadow.base.border.background; 39 | } 40 | } 41 | 42 | export const $dropdown = composer(DropDownStylesComposer); 43 | ``` 44 | 45 | Now you can use it in your components: 46 | 47 | ```tsx 48 | const UIDropDown = styled.div` 49 | ${dropdown.shadow.base.border.background}; 50 | // or 51 | ${dropdown.all}; 52 | `; 53 | ``` 54 | 55 | ## Custom composers with config 56 | 57 | In some cases, you might want your composables to have some config before emmiting styles. 58 | 59 | For example, you want to create a `ButtonStylesComposer` that will be used to style buttons. 60 | 61 | ```ts 62 | import { Composer, composerConfig, CSSProperties } from "stylings"; 63 | 64 | interface ButtonStylesConfig { 65 | size: "regular" | "small" | "medium" | "large"; 66 | kind: "primary" | "secondary" | "tertiary"; 67 | } 68 | 69 | const config = composerConfig({ 70 | size: "regular", 71 | kind: "primary", 72 | }); 73 | 74 | export class ButtonStylesComposer extends Composer { 75 | setSize(size: ButtonStylesConfig["size"]) { 76 | return this.setConfig(config, { size }); 77 | } 78 | 79 | get regular() { 80 | return this.setSize("regular"); 81 | } 82 | 83 | get small() { 84 | return this.setSize("small"); 85 | } 86 | 87 | get medium() { 88 | return this.setSize("medium"); 89 | } 90 | 91 | get large() { 92 | return this.setSize("large"); 93 | } 94 | 95 | get primary() { 96 | return this.setKind("primary"); 97 | } 98 | 99 | get secondary() { 100 | return this.setKind("secondary"); 101 | } 102 | 103 | get tertiary() { 104 | return this.setKind("tertiary"); 105 | } 106 | 107 | setKind(kind: ButtonStylesConfig["kind"]) { 108 | return this.setConfig(config, { kind }); 109 | } 110 | 111 | private getButtonPropertiesForSize(size: ButtonStylesConfig["size"]): CSSProperties { 112 | // Use some utility functions to get the values 113 | const fontSize = getFontSizeForButtonSize(size); 114 | const paddingX = getPaddingXForButtonSize(size); 115 | const paddingY = getPaddingYForButtonSize(size); 116 | const minHeight = getMinHeightForButtonSize(size); 117 | 118 | return { 119 | fontSize, 120 | padding: `${paddingY} ${paddingX}`, 121 | minHeight, 122 | }; 123 | } 124 | 125 | compile() { 126 | const { size, kind } = this.getConfig(config); 127 | 128 | return super.compile([ 129 | // Generate all styles based on config 130 | this.getButtonPropertiesForSize(size), 131 | ]); 132 | } 133 | } 134 | 135 | export const button = new ButtonStylesComposer().init(); 136 | ``` 137 | 138 | And later we can use it like this: 139 | 140 | ```tsx 141 | const UIGetStartedButton = styled.button` 142 | ${button.regular.primary}; 143 | `; 144 | ``` 145 | -------------------------------------------------------------------------------- /docs/src/content/frame.mdx: -------------------------------------------------------------------------------- 1 | 2 | # Frame 3 | 4 | Very often, multiple UI elements share some "shape". 5 | 6 | For example, buttons, inputs and selects can have the same height, padding and border radius. 7 | 8 | `$frame` composer is designed to help us with that and couple those design rules into a single object. 9 | 10 | ## Defining frame 11 | 12 | Let's define `$control` frame which we will use for buttons, inputs, selects, etc. 13 | 14 | ```tsx filename="theme.ts" {3} 15 | import { $frame } from 'stylings'; 16 | 17 | const $control = $frame({ 18 | height: 8, 19 | paddingX: 2, 20 | paddingY: 1, 21 | radius: 1, 22 | }); 23 | ``` 24 | 25 | > [!NOTE] 26 | > 27 | > By default, when using length units, each counts as `0.25rem`, similar to Tailwind CSS. You can also pass string values like `10px`, `1rem`, `1.5em`, etc. 28 | 29 | Our frame is now ready. Let's use it for some components. 30 | 31 | ```tsx filename="controls.tsx" 32 | const Button = styled.button` 33 | ${$control.minHeight.paddingX.radius}; 34 | ${$flex.center}; 35 | `; 36 | 37 | // Note textarea doesnt use min-height 38 | const Textarea = styled.textarea` 39 | ${$control.padding.radius}; 40 | `; 41 | 42 | const Select = styled.select` 43 | ${$control.minHeight.paddingX.radius}; 44 | ${$flex.alignCenter}; 45 | `; 46 | ``` 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /docs/src/content/index.mdx: -------------------------------------------------------------------------------- 1 | import { Bleed } from "nextra/components"; 2 | 3 | # Quick start 4 | 5 | `stylings` is an opinionated and joyful library that helps you write semantic, composable, reusable styles using `React` and `styled-components`. 6 | 7 | It is battle-tested and used in production at [Screen Studio](https://screen.studio). 8 | 9 | ## Goals & Motivation 10 | 11 | - Joyful and productive developer experience 12 | - Easy to maintain a consistent design language 13 | - Allows rapid iterations over UI code 14 | - Semantic styling - i.e., code should not only describe how something looks, but also what it is 15 | - Good debugging experience in devtools 16 | - Makes opinionated and automated choices on common styling patterns 17 | - Simple, chainable, composable API that is enjoyable to use 18 | - Automate some styling tasks (e.g., generate hover color variants) 19 | 20 | 21 | import { Steps } from 'nextra/components' 22 | 23 | ## Quick start 24 | 25 | Install `stylings` and use it to start styling your applications. 26 | 27 | 28 | ### Install `stylings` 29 | 30 | ```sh npm2yarn 31 | npm i stylings 32 | ``` 33 | 34 | ### Compose your first styles 35 | 36 | ```tsx filename="styles.ts" {4, 8} 37 | import { $flex, $animation } from 'stylings' 38 | 39 | // Compose styles using chainable API 40 | $flex.horizontal.alignCenter.gap(2) 41 | 42 | // Create reusable styles and organize them how you like 43 | const animations = { 44 | $fadeIn: $animation.properties({ opacity: [0, 1] }).duration("100ms").easeInOut, 45 | } 46 | ``` 47 | 48 | ### Pass your styles to your styled components 49 | 50 | ```tsx filename="Component.tsx" {6-7} 51 | import styled from 'styled-components'; 52 | import { $flex } from 'stylings'; 53 | import { animations } from './styles'; 54 | 55 | const Intro = styled.div` 56 | ${animations.$fadeIn}; 57 | ${$flex.horizontal.alignCenter.gap(2)}; 58 | `; 59 | 60 | function App() { 61 | return ( 62 | 63 | Hello World 64 | 65 | ) 66 | } 67 | ``` 68 | 69 | ### Pass styles to inline components for rapid iteration and reduced boilerplate 70 | 71 | ```tsx filename="Component.tsx" {6,8} 72 | import { UI, $flex } from 'stylings'; 73 | import { animations } from './styles'; 74 | 75 | function App() { 76 | return ( 77 | 78 | Hello World 79 | 80 | ) 81 | } 82 | ``` 83 | 84 | Inline components accept all the same props as the regular `div`, `span`, `button`, etc. tags. 85 | 86 | ### Create named inline components for more semantic styling 87 | 88 | Next to "how it looks," define "what it is" by naming your inline components. The name will also be visible in devtools for a better debugging experience. 89 | 90 | ```tsx filename="Component.tsx" {6,8} 91 | import { UI, $flex } from 'stylings'; 92 | import { animations } from './styles'; 93 | 94 | function App() { 95 | return ( 96 | 97 | Hello World 98 | 99 | ) 100 | } 101 | ``` 102 | 103 | You can dynamically access `UI.AnyComponentNameYouWant` as needed. Dynamic components are cached. For one name, the component will be created only once. 104 | 105 | You can use the convention `UI.Name_tag`, e.g., `UI.MyLink_a`, to create components with a different tag than `div`. (TypeScript will automatically infer the correct types based on this convention) 106 | 107 | 108 | 109 | --- 110 | 111 | There are many more powerful features to discover. Check out the next section to learn more. 112 | -------------------------------------------------------------------------------- /docs/src/content/inline-components.mdx: -------------------------------------------------------------------------------- 1 | import { Bleed } from "nextra/components"; 2 | 3 | # Inline Components 4 | 5 | In previous examples, we used `styled-components` to create styled parts of our app. 6 | 7 | Sometimes, we may want to use styles in a more inline way, e.g if some styles are simple or we want to iterate quickly. 8 | 9 | Inlined components allow us to do just that. 10 | 11 | ## Creating inline components 12 | 13 | Similar to `styled-components`, we can create inline components using `UI.div`, `UI.span`, `UI.button`, etc. 14 | 15 | Those behave just like regular components, but with additional `styles` prop that allows us to pass styles we want to apply. 16 | 17 | ```tsx filename="Component.tsx" {6,8} 18 | import { UI, $flex } from 'stylings'; 19 | import { animations } from './styles'; 20 | 21 | function App() { 22 | return ( 23 | 24 | Hello World 25 | 26 | ) 27 | } 28 | ``` 29 | 30 | Those styles will then be combined together into a single `className`. 31 | 32 | ## Named inline components 33 | 34 | In the example above, we described how our div looks, but we didn't describe what it is. 35 | 36 | It could make it harder to reason about the code or to understand the structure of some component, especially if we have a lot of styles and nested components. 37 | 38 | Also, it might be hard to debug in devtools, as the component name is not clearly visible in the final HTML. 39 | 40 | `UI` object can be used to dynamically create a component with **any name**. 41 | 42 | ```tsx 43 | 44 | Hello World 45 | 46 | 47 | 48 | Hello World 49 | 50 | ``` 51 | 52 | We can improve the previous example to use a named inline component. 53 | 54 | ```tsx filename="Component.tsx" {6,8} 55 | import { UI, $flex } from 'stylings'; 56 | import { animations } from './styles'; 57 | 58 | function App() { 59 | return ( 60 | 61 | Hello World 62 | 63 | ) 64 | } 65 | ``` 66 | 67 | By default, `div` will be used as the underlying tag. 68 | 69 | ### Inline components with different tag 70 | 71 | If we want to use a different tag than `div`, we can use the convention `UI.Name_tag`, e.g., `UI.MyLink_a`. 72 | 73 | 74 | ```tsx /_a/ 75 | 76 | Click me 77 | 78 | ``` 79 | 80 | TypeScript will automatically infer the correct types based on this convention. 81 | 82 | Optionally, you can pass `as` prop to the component to use a different tag, however, in this case TypeScript will not update the component type to match the new underlying tag. 83 | 84 | Finally, when our components grow it might look like this: 85 | 86 | ```tsx filename="Form.tsx" 87 | import { UI, $flex, $font } from "stylings"; 88 | 89 | import { animations, typo } from "./styles"; 90 | 91 | function Form() { 92 | return ( 93 | 94 | Form 95 | 96 | Intro here 97 | 98 | 99 | {/* ... */} 100 | {/* ... */} 101 | 102 | 103 | ); 104 | } 105 | ``` -------------------------------------------------------------------------------- /docs/src/content/performance.mdx: -------------------------------------------------------------------------------- 1 | # Performance 2 | 3 | Stylings primitives are heavily optimized for performance. 4 | 5 | ```tsx 6 | import { $flex } from "stylings"; 7 | 8 | console.time("flex"); 9 | 10 | for (let i = 0; i < 1_000_000; i++) { 11 | $flex.gap(i).vertical.alignCenter.justifyAround.compile(); 12 | } 13 | 14 | console.timeEnd("flex"); 15 | // flex: 393.7880859375 ms 16 | // M3 Max MacBook Pro 17 | ``` 18 | 19 | This is not very advanced benchmark, but in this example: 20 | 21 | - we are creating 1 million of different styles 22 | - we exit cached path early (as `.gap()` is used at the start with a new value) 23 | - we use bunch of modifiers and then compile it into the final CSS -------------------------------------------------------------------------------- /docs/src/content/reusing-styles.mdx: -------------------------------------------------------------------------------- 1 | 2 | # Reusing Styles 3 | 4 | Style composers in essence are regular JavaScript objects that can be passed around, reused and composed further into more specific styles. 5 | 6 | Let's take a look at some examples. 7 | 8 | ## Example - typography 9 | 10 | Let's say we have some base font style that we want to reuse across our app. 11 | 12 | We can define it as a base font composer and then compose it into more specific styles. 13 | 14 | ```tsx filename="styles.ts" /$baseFont/ /typo/ 15 | import { $font } from 'stylings'; 16 | 17 | /** 18 | * Define a base font style with properties that are common to text styles we will use. 19 | * Then we can compose it into more specific fonts. 20 | */ 21 | const $baseFont = $font.family("Inter, sans-serif").size("1rem").lineHeight(1.5).antialiased; 22 | 23 | const typo = { 24 | $copy: $baseFont, 25 | // We can override properties from the base font if needed 26 | $heading: $baseFont.size("2rem").lineHeight(1.25).bold, 27 | $label: $baseFont.size("0.875rem"), 28 | } 29 | ``` 30 | 31 | Now, our base font is applied to various text styles. 32 | 33 | As composer is chainable, we can continue to compose them into even more specific styles. 34 | 35 | ```tsx filename="SmallLink.tsx" /typo.$label/ /typo/ /underline/ 36 | import { typo } from './styles'; 37 | import styled from 'styled-components'; 38 | 39 | function SmallLink() { 40 | return ( 41 | 42 | Click me 43 | 44 | ) 45 | } 46 | 47 | const StyledLink = styled.a` 48 | ${typo.$label.underline} 49 | `; 50 | ``` 51 | 52 | Here, we took our label style and added underline to it. 53 | 54 | ## Example - animations 55 | 56 | In similar way, we can define base animation style and then compose it into more specific animations. 57 | 58 | Let's say all animations in our app have the same duration and easing. 59 | 60 | ```tsx filename="styles.ts" /$quickEasedAnimation/ /animations/ 61 | import { $animation } from 'stylings'; 62 | 63 | /** 64 | * We can first define some base animation style such as duration and easing function. 65 | * Then we can compose it into more specific animations. 66 | */ 67 | const $quickEasedAnimation = $animation.duration(200).easeInOut; 68 | 69 | const animations = { 70 | fadeIn: $quickEasedAnimation.property('opacity', [0, 1]), 71 | fadeOut: $quickEasedAnimation.property('opacity', [1, 0]), 72 | slideUp: $quickEasedAnimation.property('y', [0, -10]), 73 | slideDown: $quickEasedAnimation.property('y', [0, 10]), 74 | spin: $animation.property('rotateZ', [0, 360]).linear.infinite, 75 | } 76 | ``` 77 | 78 | --- 79 | 80 | We can perform similar composition with all style composers. -------------------------------------------------------------------------------- /docs/src/content/themes.mdx: -------------------------------------------------------------------------------- 1 | # Themes 2 | 3 | Stylings provides a powerful theming system that allows you to create and manage theme variants while maintaining type safety and performance. 4 | 5 | ## Creating Themes 6 | 7 | Themes are created using the `createTheme` function. It takes an object where you can pass composable and primitive values. 8 | 9 | ```tsx 10 | import { createTheme, color, font } from "stylings"; 11 | 12 | const baseFont = font.family("Inter, sans-serif").lineHeight(1.5).antialiased; 13 | 14 | const theme = createTheme({ 15 | // Primitive values 16 | spacing: 16, 17 | 18 | // Typography styles 19 | typo: { 20 | base: baseFont, 21 | header: baseFont.size("2rem").bold, 22 | }, 23 | 24 | // Color styles 25 | colors: { 26 | primary: color({ color: "red" }), 27 | text: color({ color: "black" }), 28 | background: color({ color: "white" }), 29 | }, 30 | }); 31 | ``` 32 | 33 | ## Theme Variants 34 | 35 | You can then create theme variants that inherit from a source theme but override specific values: 36 | 37 | ```tsx 38 | import { createThemeVariant } from "stylings"; 39 | import { theme } from "./theme"; 40 | 41 | const darkTheme = createThemeVariant(theme, { 42 | // Note: we can only pass values that we want to override. Everything else will be taken from the source theme. 43 | colors: { 44 | text: color({ color: "white" }), 45 | background: color({ color: "black" }), 46 | }, 47 | }); 48 | ``` 49 | 50 | ## Theme provider 51 | 52 | To use the theme, you need to wrap your app in the `ThemeProvider` component. 53 | 54 | ```tsx 55 | import { ThemeProvider } from "stylings"; 56 | import { theme, darkTheme } from "./theme"; 57 | 58 | function App() { 59 | const [isDarkThemeActive, setIsDarkThemeActive] = useState(false); 60 | 61 | function toggleDarkMode() { 62 | setIsDarkThemeActive(!isDarkThemeActive); 63 | } 64 | 65 | return ( 66 | 67 | 68 | 69 | ); 70 | } 71 | ``` 72 | 73 | 74 | ## Using Themes 75 | 76 | To use theme, you simply read values from the theme object created before. 77 | 78 | ```tsx 79 | import { theme } from "./theme"; 80 | 81 | function Card() { 82 | return ( 83 | 84 | 85 | Card content 86 | 87 | ); 88 | } 89 | 90 | // Or 91 | 92 | const UICard = styled.div` 93 | ${theme.colors.background.readableText.asBg}; 94 | `; 95 | ``` 96 | 97 | ### Reading Theme Values 98 | 99 | If your theme defines some primitive values, you can read them using `useThemeValue` hook. 100 | 101 | ```tsx 102 | import { useThemeValue, createTheme } from "stylings"; 103 | 104 | // theme.ts 105 | const theme = createTheme({ 106 | footerColumns: 3, 107 | }); 108 | 109 | const wideTheme = createThemeVariant(theme, { 110 | footerColumns: 4, 111 | }); 112 | 113 | // Footer.tsx 114 | function Footer() { 115 | const footerColumns = useThemeValue(theme.footerColumns); 116 | return
Hello
; 117 | } 118 | ``` 119 | 120 | Optionally, anywhere (even outside of React) you can read theme values by calling theme values with `theme` argument. 121 | 122 | ```ts 123 | const footerColumns = theme.footerColumns(theme); // 3 124 | const wideFooterColumns = theme.footerColumns(wideTheme); // 4 125 | ``` 126 | 127 | Note: All the code above is fully typed and will give you autocomplete and type safety. 128 | -------------------------------------------------------------------------------- /docs/src/content/using-styles.mdx: -------------------------------------------------------------------------------- 1 | 2 | # Using Styles 3 | 4 | At the core of `stylings` are style **style composers**. 5 | 6 | Style composers are objects that collect desired styles and can be used to generate CSS. 7 | 8 | There are several built-in style composers: `$flex`, `$grid`, `$animation`, `$colors`, `$frame`, `$font`, `$shadow`, `$common`, `$transition`. 9 | 10 | ```tsx {3} {5-9} 11 | import { $flex } from "stylings"; 12 | 13 | $flex.horizontal.alignCenter.gap(2); 14 | 15 | // This represents the following CSS: 16 | // display: flex; 17 | // flex-direction: row; 18 | // align-items: center; 19 | // gap: 1rem; 20 | ``` 21 | 22 | ## Chaining style composers 23 | 24 | Style composers are infinitely chainable using their **modifiers**. 25 | 26 | Some modifiers can be used without arguments, e.g., 27 | 28 | ```tsx /horizontal/ /center/ 29 | $flex.horizontal.center; 30 | ``` 31 | 32 | Some modifiers require arguments, e.g., 33 | 34 | ```tsx /gap(2)/ 35 | $flex.gap(2).horizontal.center; 36 | ``` 37 | 38 | You can use the same modifier multiple times (new modifiers will override previous ones), e.g., 39 | 40 | ```tsx /horizontal.center/ 41 | $flex.horizontal.center.horizontal.center; 42 | ``` 43 | 44 | is technically valid and is the same as 45 | 46 | ```tsx /horizontal.center/ 47 | $flex.horizontal.center; 48 | ``` 49 | 50 | ## Using style composers with `styled-components` 51 | 52 | Style composers can be directly passed to styled components. 53 | 54 | ```tsx /$flex/ {12} /ListOfItems/ 55 | import { $flex } from "stylings"; 56 | 57 | function Items() { 58 | return 59 |
Item 1
60 |
Item 2
61 |
Item 3
62 |
63 | } 64 | 65 | const ListOfItems = styled.div` 66 | ${$flex.vertical.gap(2)} 67 | `; 68 | ``` 69 | 70 | 71 | ## Naming conventions 72 | 73 | All composers are named with a `$` prefix. This is to make them easily recognizable and clearly indicate that they are chainable. 74 | 75 | If you're exporting composers from a file, it is recommended to also add a `$` prefix to the name of the variable. 76 | 77 | ```tsx /$/ 78 | export const $formField = $flex.horizontal.alignStretch.gap(2); 79 | ``` 80 | 81 | 82 | ## Manually getting final CSS out of style composers 83 | 84 | > [!tip] 85 | > 86 | > You can skip this section as you will rarely need to do that manually. Composers are automatically compiled when used, e.g., as a part of styled-components. 87 | 88 | To read values from composers, you can: 89 | - call its `composer.compile()` method 90 | - or call the composer as a function, e.g `composer()` (`styled-components` does this automatically) 91 | 92 | 93 | ```tsx /compile()/ /()/ 94 | const $styles = $flex.horizontal.alignCenter.gap(2); 95 | 96 | $styles.compile(); 97 | // above is equivalent to: 98 | $styles(); 99 | 100 | // both will output: 101 | [ 102 | "display: flex", 103 | "flex-direction: row", 104 | "align-items: center", 105 | "gap: 1rem", 106 | ] 107 | ``` 108 | 109 | > [!note] 110 | > 111 | > Under the hood, composers behave both as functions and objects. Calling them as a function is an alias for calling `.compile()`. 112 | > 113 | > This is needed because of how `styled-components` work. They accept functions as part of styles and then resolve them. If in the future, styled-components will, for example, support some API that allows custom objects to be used as styles, it will be possible to remove this distinction. -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "noEmit": true, 13 | "incremental": true, 14 | "module": "esnext", 15 | "esModuleInterop": true, 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ] 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | ".next/types/**/*.ts", 29 | "**/*.ts", 30 | "**/*.tsx" 31 | ], 32 | "exclude": [ 33 | "node_modules" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stylings-root", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "private": true, 6 | "workspaces": { 7 | "packages": [ 8 | "stylings", 9 | "docs" 10 | ] 11 | }, 12 | "scripts": { 13 | "stylings": "yarn workspace stylings", 14 | "docs": "yarn workspace stylings-docs" 15 | }, 16 | "packageManager": "yarn@4.4.1" 17 | } 18 | -------------------------------------------------------------------------------- /stylings/.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | *.ts 4 | *.tsx 5 | 6 | # Development configs 7 | .vscode/ 8 | .idea/ 9 | *.config.ts 10 | .eslintrc* 11 | .prettierrc* 12 | .editorconfig 13 | .gitignore 14 | .npmrc 15 | .yarnrc* 16 | .yarn/ 17 | 18 | # Test files 19 | __tests__/ 20 | *.test.ts 21 | *.test.tsx 22 | coverage/ 23 | .nyc_output/ 24 | 25 | # Development scripts 26 | scripts/ 27 | demo/ 28 | examples/ 29 | 30 | # Build tools 31 | vite.config.* 32 | tsconfig.json 33 | jest.config.* 34 | vitest.config.* 35 | 36 | # Documentation 37 | docs/ 38 | *.md 39 | !README.md 40 | !LICENSE 41 | 42 | # Misc 43 | .DS_Store 44 | *.log 45 | .env* 46 | .cache/ 47 | .temp/ 48 | .tmp/ -------------------------------------------------------------------------------- /stylings/README.md: -------------------------------------------------------------------------------- 1 | # stylings 2 | 3 | `stylings` is an opinionated and joyful library that helps you write semantic, composable, reusable styles using `React` and `styled-components`. 4 | 5 | It is battle-tested and used in production at [Screen Studio](https://screen.studio). 6 | 7 | Docs - [stylings.dev](https://stylings.dev). 8 | 9 | ## Goals & Motivation 10 | 11 | - Joyful and productive developer experience 12 | - Easy to maintain a consistent design language 13 | - Allows rapid iterations over UI code 14 | - Semantic styling - i.e., code should not only describe how something looks, but also what it is 15 | - Good debugging experience in devtools 16 | - Makes opinionated and automated choices on common styling patterns 17 | - Simple, chainable, composable API that is enjoyable to use 18 | - Automate some styling tasks (e.g., generate hover color variants) 19 | 20 | ## Installation 21 | 22 | ```sh 23 | npm i stylings 24 | # or 25 | yarn add stylings 26 | ``` 27 | 28 | ## Quick Start 29 | 30 | ### Compose your first styles 31 | 32 | ```tsx 33 | import { $flex, $animation } from "stylings"; 34 | 35 | // Compose styles using chainable API 36 | $flex.horizontal.alignCenter.gap(2); 37 | 38 | // Create reusable styles and organize them how you like 39 | const animations = { 40 | $fadeIn: $animation.properties({ opacity: [0, 1] }).duration("100ms").easeInOut, 41 | }; 42 | ``` 43 | 44 | ### Use with styled-components 45 | 46 | ```tsx 47 | import styled from "styled-components"; 48 | import { $flex } from "stylings"; 49 | import { animations } from "./styles"; 50 | 51 | const Intro = styled.div` 52 | ${animations.$fadeIn}; 53 | ${$flex.horizontal.alignCenter.gap(2)}; 54 | `; 55 | 56 | function App() { 57 | return Hello World; 58 | } 59 | ``` 60 | 61 | ### Use with inline components 62 | 63 | ```tsx 64 | import { UI, $flex } from "stylings"; 65 | import { animations } from "./styles"; 66 | 67 | function App() { 68 | return Hello World; 69 | } 70 | ``` 71 | 72 | ## Core Features 73 | 74 | ### Style Composers 75 | 76 | At the core of `stylings` are style composers. These are objects that collect desired styles and can be used to generate CSS. The main built-in composers are: 77 | 78 | - `$flex` - for flexbox layouts 79 | - `$grid` - for grid layouts 80 | - `$animation` - for animations 81 | - `$colors` - for color management 82 | - `$frame` - for consistent UI element shapes 83 | - `$font` - for typography 84 | - `$shadow` - for box shadows 85 | - `$common` - for common styles 86 | - `$transition` - for transitions 87 | 88 | ### Working with Colors 89 | 90 | ```tsx 91 | import { $color } from "stylings"; 92 | 93 | // Define a color 94 | const $primary = $color("#048"); 95 | 96 | // Use it in styles 97 | const Button = styled.button` 98 | ${$primary.interactive.asBg}; // Interactive adds hover states 99 | `; 100 | ``` 101 | 102 | ### Frame System 103 | 104 | The Frame system helps maintain consistent sizing and spacing across UI elements: 105 | 106 | ```tsx 107 | import { $frame } from "stylings"; 108 | 109 | const $control = $frame({ 110 | height: 8, 111 | paddingX: 2, 112 | paddingY: 1, 113 | radius: 1, 114 | }); 115 | 116 | const Button = styled.button` 117 | ${$control.minHeight.paddingX.radius}; 118 | ${$flex.center}; 119 | `; 120 | ``` 121 | 122 | ### Theming System 123 | 124 | ```tsx 125 | import { createTheme, color, font } from "stylings"; 126 | 127 | const baseFont = font.family("Inter, sans-serif").lineHeight(1.5).antialiased; 128 | 129 | const theme = createTheme({ 130 | spacing: 16, 131 | typo: { 132 | base: baseFont, 133 | header: baseFont.size("2rem").bold, 134 | }, 135 | colors: { 136 | primary: color({ color: "red" }), 137 | text: color({ color: "black" }), 138 | background: color({ color: "white" }), 139 | }, 140 | }); 141 | 142 | // Create theme variants 143 | const darkTheme = createThemeVariant(theme, { 144 | colors: { 145 | text: color({ color: "white" }), 146 | background: color({ color: "black" }), 147 | }, 148 | }); 149 | ``` 150 | 151 | ### Custom Composers 152 | 153 | You can create your own composable styles: 154 | 155 | ```tsx 156 | import { Composer, composer } from "stylings"; 157 | 158 | export class DropDownStylesComposer extends Composer { 159 | get shadow() { 160 | return this.addStyle({ boxShadow: "0 0 10px 0 rgba(0, 0, 0, 0.1)" }); 161 | } 162 | 163 | get base() { 164 | return this.addStyle({ 165 | padding: "1rem", 166 | borderRadius: "0.5rem", 167 | }); 168 | } 169 | 170 | // ... more styles 171 | 172 | get all() { 173 | return this.shadow.base.border.background; 174 | } 175 | } 176 | 177 | export const $dropdown = composer(DropDownStylesComposer); 178 | ``` 179 | 180 | ## Conventions 181 | 182 | ### Gaps 183 | 184 | The `.gap(level)` modifier on `$flex` and `$grid` uses exponential growth: 185 | 186 | ```tsx 187 | $flex.gap(1); // 0.5rem 188 | $flex.gap(2); // 1rem 189 | $flex.gap(3); // 2rem 190 | $flex.gap(4); // 4rem 191 | ``` 192 | 193 | ## License 194 | 195 | MIT License 196 | 197 | Copyright (c) 2024 Adam Pietrasiak 198 | 199 | Permission is hereby granted, free of charge, to any person obtaining a copy 200 | of this software and associated documentation files (the "Software"), to deal 201 | in the Software without restriction, including without limitation the rights 202 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 203 | copies of the Software, and to permit persons to whom the Software is 204 | furnished to do so, subject to the following conditions: 205 | 206 | The above copyright notice and this permission notice shall be included in all 207 | copies or substantial portions of the Software. 208 | 209 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 210 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 211 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 212 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 213 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 214 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 215 | SOFTWARE. 216 | -------------------------------------------------------------------------------- /stylings/demo/Demo.tsx: -------------------------------------------------------------------------------- 1 | import { $animation, $flex, $font, UI } from "@"; 2 | 3 | import { styled } from "styled-components"; 4 | 5 | export function Demo() { 6 | return ( 7 | 8 | Hello, world! 9 | Hello, world! 10 | 11 | ); 12 | } 13 | 14 | const text = $font.family("Inter, sans-serif").lineHeight(1.5).antialiased; 15 | 16 | const UIContent = styled.div` 17 | ${text}; 18 | ${$flex.vertical.gap(2).horizontal.alignCenter.alignEnd.gap()}; 19 | `; 20 | 21 | for (let i = 0; i < 10000; i++) { 22 | $flex.vertical.gap(2).horizontal.alignCenter.alignEnd.gap(4)(); 23 | } 24 | -------------------------------------------------------------------------------- /stylings/demo/app.tsx: -------------------------------------------------------------------------------- 1 | import "./dev"; 2 | 3 | import { Demo } from "./Demo"; 4 | import { createRoot } from "react-dom/client"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | 8 | if (rootElement) { 9 | const root = createRoot(rootElement); 10 | root.render(); 11 | } 12 | -------------------------------------------------------------------------------- /stylings/demo/dev.ts: -------------------------------------------------------------------------------- 1 | import * as sw from "@"; 2 | 3 | Reflect.set(window, "sw", sw); 4 | -------------------------------------------------------------------------------- /stylings/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Stylings Demo 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /stylings/demo/index.tsx: -------------------------------------------------------------------------------- 1 | // import "./app"; 2 | // import "./playground"; 3 | import "./perf"; 4 | -------------------------------------------------------------------------------- /stylings/demo/perf.tsx: -------------------------------------------------------------------------------- 1 | import * as sw from "@"; 2 | 3 | import { $color, $flex, createTheme, createThemeVariant } from "@"; 4 | 5 | Reflect.set(window, "sw", sw); 6 | 7 | console.time("first"); 8 | 9 | $flex.vertical.gap(2).alignCenter.justifyAround(); 10 | $flex.vertical.gap(3).alignCenter.justifyAround(); 11 | $flex.vertical.gap(4).alignCenter.justifyAround(); 12 | $flex.vertical.gap(5).alignCenter.justifyAround(); 13 | $flex.vertical.gap(6).alignCenter.justifyAround(); 14 | $flex.vertical.gap(7).alignCenter.justifyAround(); 15 | $flex.vertical.gap(8).alignCenter.justifyAround(); 16 | $flex.vertical.gap(9).alignCenter.justifyAround(); 17 | $flex.vertical.gap(10).alignCenter.justifyAround(); 18 | 19 | console.timeEnd("first"); 20 | 21 | console.time("cached"); 22 | 23 | $flex.vertical.gap(2).alignCenter.justifyAround(); 24 | $flex.vertical.gap(3).alignCenter.justifyAround(); 25 | $flex.vertical.gap(4).alignCenter.justifyAround(); 26 | $flex.vertical.gap(5).alignCenter.justifyAround(); 27 | $flex.vertical.gap(6).alignCenter.justifyAround(); 28 | $flex.vertical.gap(7).alignCenter.justifyAround(); 29 | $flex.vertical.gap(8).alignCenter.justifyAround(); 30 | $flex.vertical.gap(9).alignCenter.justifyAround(); 31 | $flex.vertical.gap(10).alignCenter.justifyAround(); 32 | 33 | console.timeEnd("cached"); 34 | 35 | console.time("flex"); 36 | 37 | const color = $color({ color: "red" }); 38 | 39 | const ITERATIONS = 10000; 40 | 41 | for (let i = 0; i < ITERATIONS; i++) { 42 | $flex.gap(`${i}px`).vertical.alignEnd.justifyAround(); 43 | // color.opacity(0.5).withBorder.asOutline.asBg(); 44 | // color.opacity(0.5).withBorder.asOutline.asBg(); 45 | // color.opacity(0.5).withBorder.asOutline.asBg(); 46 | // color.opacity(0.5).withBorder.asOutline.asBg(); 47 | // color.opacity(0.5).withBorder.asOutline.asBg(); 48 | } 49 | 50 | console.timeEnd("flex"); 51 | 52 | const theme = createTheme({ 53 | color: $color({ color: "red" }), 54 | }); 55 | 56 | const variant = createThemeVariant(theme, { 57 | color: $color({ color: "blue" }), 58 | }); 59 | 60 | const themedFlex = createTheme({ 61 | $flex, 62 | }); 63 | 64 | console.log(themedFlex); 65 | 66 | console.time("theme"); 67 | 68 | for (let i = 0; i < ITERATIONS; i++) { 69 | themedFlex.$flex.gap(`${i}px`).vertical.alignCenter.justifyAround(variant); 70 | // theme.color.opacity(0.5).withBorder.asOutline.asBg(variant); 71 | // theme.color.opacity(0.5).withBorder.asOutline.asBg(variant); 72 | // theme.color.opacity(0.5).withBorder.asOutline.asBg(variant); 73 | // theme.color.opacity(0.5).withBorder.asOutline.asBg(variant); 74 | // theme.color.opacity(0.5).withBorder.asOutline.asBg(variant); 75 | } 76 | 77 | console.timeEnd("theme"); 78 | -------------------------------------------------------------------------------- /stylings/demo/playground.tsx: -------------------------------------------------------------------------------- 1 | import * as sw from "@"; 2 | 3 | import { $flex, createTheme } from "@"; 4 | 5 | Reflect.set(window, "sw", sw); 6 | 7 | const { $flex: themedFlex } = createTheme({ 8 | $flex, 9 | }); 10 | 11 | themedFlex.gap(2).vertical.alignCenter.justifyAround(); 12 | -------------------------------------------------------------------------------- /stylings/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stylings", 3 | "homepage": "https://stylings.dev", 4 | "license": "MIT", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/pie6k/stylings.git" 8 | }, 9 | "version": "1.0.6", 10 | "type": "module", 11 | "author": { 12 | "name": "Adam Pietrasiak" 13 | }, 14 | "description": "An opinionated and joyful library for writing semantic, composable, and reusable styles in React using styled-components. Features chainable style composers, inline components, theming system, and automated style variants.", 15 | "keywords": [ 16 | "react", 17 | "styled-components", 18 | "css-in-js", 19 | "styling", 20 | "theme", 21 | "semantic-styling", 22 | "composable-styles", 23 | "inline-components", 24 | "style-composers", 25 | "flexbox", 26 | "grid", 27 | "animations", 28 | "colors", 29 | "typography", 30 | "design-system" 31 | ], 32 | "main": "./dist/index.cjs.js", 33 | "module": "./dist/index.es.js", 34 | "types": "./dist/index.d.ts", 35 | "exports": { 36 | ".": { 37 | "import": "./dist/index.es.js", 38 | "require": "./dist/index.cjs.js" 39 | } 40 | }, 41 | "scripts": { 42 | "dev": "vite --config vite.config.demo.ts", 43 | "build": "vite --config vite.config.build.ts build", 44 | "preview": "vite preview", 45 | "prettier": "prettier --write \"src/**/*.{ts,tsx}\"", 46 | "test": "vitest" 47 | }, 48 | "devDependencies": { 49 | "@types/node": "^22.13.11", 50 | "@types/react": "*", 51 | "@types/react-dom": "*", 52 | "@types/styled-components": "*", 53 | "prettier": "^3.0.3", 54 | "react": "*", 55 | "react-dom": "*", 56 | "styled-components": "*", 57 | "typescript": "*", 58 | "vite": "^4.4.9", 59 | "vite-plugin-dts": "^4.5.3", 60 | "vitest": "^3.0.9" 61 | }, 62 | "files": [ 63 | "dist" 64 | ], 65 | "dependencies": { 66 | "color": "^5.0.0", 67 | "csstype": "^3.1.3", 68 | "fast-equals": "^5.2.2" 69 | }, 70 | "peerDependencies": { 71 | "react": "*", 72 | "styled-components": "*" 73 | }, 74 | "packageManager": "yarn@4.4.1" 75 | } 76 | -------------------------------------------------------------------------------- /stylings/src/AnimationComposer.ts: -------------------------------------------------------------------------------- 1 | import type { Property, StandardPropertiesHyphen } from "csstype"; 2 | import { css, keyframes } from "styled-components"; 3 | import { resolveMaybeBaseValue, resolveMaybeBaseValues } from "./SizeComposer"; 4 | import { type Length, addUnit, isInteger } from "./utils"; 5 | 6 | import { simplifyRule } from "./compilation"; 7 | import { Composer, composer } from "./Composer"; 8 | import { ComposerConfig } from "./ComposerConfig"; 9 | import { getHasValue } from "./utils/maybeValue"; 10 | 11 | type PropertyAnimationSteps = Array; 12 | 13 | type AnimatedTransformProperties = { 14 | "transform-x": Length; 15 | "transform-y": Length; 16 | "transform-scale": Length; 17 | "transform-rotate": Length; 18 | }; 19 | 20 | type FilterTransformProperties = { 21 | "filter-blur": Length; 22 | "filter-brightness": Length; 23 | "filter-contrast": Length; 24 | "filter-drop-shadow": Length; 25 | "filter-grayscale": Length; 26 | "filter-hue-rotate": Length; 27 | "filter-invert": Length; 28 | "filter-opacity": Length; 29 | "filter-saturate": Length; 30 | "filter-sepia": Length; 31 | }; 32 | 33 | type BackdropFilterTransformProperties = { 34 | "backdrop-blur": Length; 35 | "backdrop-brightness": Length; 36 | "backdrop-contrast": Length; 37 | "backdrop-drop-shadow": Length; 38 | "backdrop-grayscale": Length; 39 | "backdrop-hue-rotate": Length; 40 | "backdrop-invert": Length; 41 | "backdrop-opacity": Length; 42 | "backdrop-saturate": Length; 43 | "backdrop-sepia": Length; 44 | }; 45 | 46 | interface AnimatableProperties 47 | extends AnimatedTransformProperties, 48 | FilterTransformProperties, 49 | BackdropFilterTransformProperties, 50 | StandardPropertiesHyphen {} 51 | 52 | type PropertiesSteps = { 53 | [key in keyof AnimatableProperties]?: PropertyAnimationSteps; 54 | }; 55 | 56 | export function getHasAnimationProperty(property: keyof AnimatableProperties, properties: PropertiesSteps) { 57 | return properties[property] !== undefined; 58 | } 59 | 60 | interface StyledAnimationConfig { 61 | duration: Length; 62 | easing: Property.AnimationTimingFunction; 63 | properties?: PropertiesSteps; 64 | } 65 | 66 | function getKeyframePercentageLabel(value: number) { 67 | const percent = value * 100; 68 | 69 | if (isInteger(percent)) return `${percent}%`; 70 | 71 | return `${percent.toFixed(2)}%`; 72 | } 73 | 74 | function preaparePercentageVariableLabel(progress: number) { 75 | return getKeyframePercentageLabel(progress).replace(".", "_").replace("%", ""); 76 | } 77 | 78 | function getAnimationStepVariableName(property: keyof AnimatableProperties, progress: number) { 79 | const progressLabel = preaparePercentageVariableLabel(progress); 80 | const finalProperty = property.replace("transform-", ""); 81 | return `--animate_${finalProperty}_${progressLabel}`; 82 | } 83 | 84 | function getWillChangeProperties(properties: PropertiesSteps) { 85 | const willChangeProperties = new Set(); 86 | 87 | const keys = Object.keys(properties); 88 | 89 | if (keys.some((k) => k.startsWith("transform"))) { 90 | willChangeProperties.add("transform"); 91 | } 92 | 93 | if (keys.some((k) => k.startsWith("filter"))) { 94 | willChangeProperties.add("filter"); 95 | } 96 | 97 | if (keys.some((k) => k.startsWith("backdrop"))) { 98 | willChangeProperties.add("backdrop-filter"); 99 | } 100 | 101 | if (keys.some((k) => k.startsWith("opacity"))) { 102 | willChangeProperties.add("opacity"); 103 | } 104 | 105 | if (keys.some((k) => k.startsWith("color"))) { 106 | willChangeProperties.add("color"); 107 | } 108 | 109 | if (keys.some((k) => k.startsWith("background"))) { 110 | willChangeProperties.add("background"); 111 | } 112 | 113 | if (!willChangeProperties.size) return null; 114 | 115 | return Array.from(willChangeProperties); 116 | } 117 | 118 | function getPropertyAnimationVariables( 119 | property: T, 120 | steps: PropertyAnimationSteps, 121 | ): Record { 122 | const result: Record = {}; 123 | 124 | for (const [index, step] of steps.entries()) { 125 | const progress = index / (steps.length - 1); 126 | const variableName = getAnimationStepVariableName(property, progress); 127 | 128 | result[variableName] = `${step}`; 129 | } 130 | 131 | return result; 132 | } 133 | 134 | function mergeRecords(records: Record[]) { 135 | return records.reduce((acc, record) => { 136 | return { ...acc, ...record }; 137 | }, {}); 138 | } 139 | 140 | function getPropertiesAnimationVariables(properties: PropertiesSteps): Record { 141 | const variablesPerProperty = Object.entries(properties) 142 | .filter(([property]) => getDoesNeedVariables(property as keyof AnimatableProperties)) 143 | .map(([property, steps]) => { 144 | return getPropertyAnimationVariables(property as keyof AnimatableProperties, steps); 145 | }); 146 | 147 | return mergeRecords(variablesPerProperty); 148 | } 149 | 150 | function getIsTransformProperty(property: keyof AnimatableProperties) { 151 | return ["transform-x", "transform-y", "transform-scale", "transform-rotate"].includes(property); 152 | } 153 | 154 | function getIsFilterProperty(property: keyof AnimatableProperties) { 155 | return [ 156 | "filter-blur", 157 | "filter-brightness", 158 | "filter-contrast", 159 | "filter-drop-shadow", 160 | "filter-grayscale", 161 | "filter-hue-rotate", 162 | "filter-invert", 163 | "filter-opacity", 164 | "filter-saturate", 165 | "filter-sepia", 166 | ].includes(property); 167 | } 168 | 169 | function getIsBackdropFilterProperty(property: keyof AnimatableProperties) { 170 | return [ 171 | "backdrop-blur", 172 | "backdrop-brightness", 173 | "backdrop-contrast", 174 | "backdrop-drop-shadow", 175 | "backdrop-grayscale", 176 | "backdrop-hue-rotate", 177 | "backdrop-invert", 178 | "backdrop-opacity", 179 | "backdrop-saturate", 180 | "backdrop-sepia", 181 | ].includes(property); 182 | } 183 | 184 | function getTransformAnimationStyleString(progress: number, properties: PropertiesSteps) { 185 | const xVar = getAnimationStepVariableName("transform-x", progress); 186 | const yVar = getAnimationStepVariableName("transform-y", progress); 187 | const scaleVar = getAnimationStepVariableName("transform-scale", progress); 188 | const rotateVar = getAnimationStepVariableName("transform-rotate", progress); 189 | 190 | const transforms: string[] = []; 191 | 192 | if (getHasAnimationProperty("transform-x", properties) || getHasAnimationProperty("transform-y", properties)) { 193 | transforms.push(`translate(var(${xVar}, 0), var(${yVar}, 0))`); 194 | } 195 | 196 | if (getHasAnimationProperty("transform-scale", properties)) { 197 | transforms.push(`scale(var(${scaleVar}, 1))`); 198 | } 199 | 200 | if (getHasAnimationProperty("transform-rotate", properties)) { 201 | transforms.push(`rotate(var(${rotateVar}, 0))`); 202 | } 203 | 204 | return ` 205 | transform: ${transforms.join(" ")}; 206 | `; 207 | } 208 | 209 | function getFilterAnimationStyleString(progress: number, properties: PropertiesSteps) { 210 | const blurVar = getAnimationStepVariableName("filter-blur", progress); 211 | const brightnessVar = getAnimationStepVariableName("filter-brightness", progress); 212 | const contrastVar = getAnimationStepVariableName("filter-contrast", progress); 213 | const dropShadowVar = getAnimationStepVariableName("filter-drop-shadow", progress); 214 | const grayscaleVar = getAnimationStepVariableName("filter-grayscale", progress); 215 | const hueRotateVar = getAnimationStepVariableName("filter-hue-rotate", progress); 216 | const invertVar = getAnimationStepVariableName("filter-invert", progress); 217 | const opacityVar = getAnimationStepVariableName("filter-opacity", progress); 218 | const saturateVar = getAnimationStepVariableName("filter-saturate", progress); 219 | const sepiaVar = getAnimationStepVariableName("filter-sepia", progress); 220 | 221 | const filters: string[] = []; 222 | 223 | if (getHasAnimationProperty("filter-blur", properties)) { 224 | filters.push(`blur(var(${blurVar}, 0))`); 225 | } 226 | 227 | if (getHasAnimationProperty("filter-brightness", properties)) { 228 | filters.push(`brightness(var(${brightnessVar}, 1))`); 229 | } 230 | 231 | if (getHasAnimationProperty("filter-contrast", properties)) { 232 | filters.push(`contrast(var(${contrastVar}, 1))`); 233 | } 234 | 235 | if (getHasAnimationProperty("filter-drop-shadow", properties)) { 236 | filters.push(`drop-shadow(var(${dropShadowVar}, 0))`); 237 | } 238 | 239 | if (getHasAnimationProperty("filter-grayscale", properties)) { 240 | filters.push(`grayscale(var(${grayscaleVar}, 0))`); 241 | } 242 | 243 | if (getHasAnimationProperty("filter-hue-rotate", properties)) { 244 | filters.push(`hue-rotate(var(${hueRotateVar}, 0))`); 245 | } 246 | 247 | if (getHasAnimationProperty("filter-invert", properties)) { 248 | filters.push(`invert(var(${invertVar}, 0))`); 249 | } 250 | 251 | if (getHasAnimationProperty("filter-opacity", properties)) { 252 | filters.push(`opacity(var(${opacityVar}, 1))`); 253 | } 254 | 255 | if (getHasAnimationProperty("filter-saturate", properties)) { 256 | filters.push(`saturate(var(${saturateVar}, 1))`); 257 | } 258 | 259 | if (getHasAnimationProperty("filter-sepia", properties)) { 260 | filters.push(`sepia(var(${sepiaVar}, 0))`); 261 | } 262 | 263 | return ` 264 | filter: ${filters.join(" ")}; 265 | `; 266 | } 267 | 268 | function getBackdropFilterAnimationStyleString(progress: number, properties: PropertiesSteps) { 269 | const blurVar = getAnimationStepVariableName("backdrop-blur", progress); 270 | const brightnessVar = getAnimationStepVariableName("backdrop-brightness", progress); 271 | const contrastVar = getAnimationStepVariableName("backdrop-contrast", progress); 272 | const dropShadowVar = getAnimationStepVariableName("backdrop-drop-shadow", progress); 273 | const grayscaleVar = getAnimationStepVariableName("backdrop-grayscale", progress); 274 | const hueRotateVar = getAnimationStepVariableName("backdrop-hue-rotate", progress); 275 | const invertVar = getAnimationStepVariableName("backdrop-invert", progress); 276 | const opacityVar = getAnimationStepVariableName("backdrop-opacity", progress); 277 | const saturateVar = getAnimationStepVariableName("backdrop-saturate", progress); 278 | const sepiaVar = getAnimationStepVariableName("backdrop-sepia", progress); 279 | 280 | const filters: string[] = []; 281 | 282 | if (getHasAnimationProperty("backdrop-blur", properties)) { 283 | filters.push(`blur(var(${blurVar}, 0))`); 284 | } 285 | 286 | if (getHasAnimationProperty("backdrop-brightness", properties)) { 287 | filters.push(`brightness(var(${brightnessVar}, 1))`); 288 | } 289 | 290 | if (getHasAnimationProperty("backdrop-contrast", properties)) { 291 | filters.push(`contrast(var(${contrastVar}, 1))`); 292 | } 293 | 294 | if (getHasAnimationProperty("backdrop-drop-shadow", properties)) { 295 | filters.push(`drop-shadow(var(${dropShadowVar}, 0))`); 296 | } 297 | 298 | if (getHasAnimationProperty("backdrop-grayscale", properties)) { 299 | filters.push(`grayscale(var(${grayscaleVar}, 0))`); 300 | } 301 | 302 | if (getHasAnimationProperty("backdrop-hue-rotate", properties)) { 303 | filters.push(`hue-rotate(var(${hueRotateVar}, 0))`); 304 | } 305 | 306 | if (getHasAnimationProperty("backdrop-invert", properties)) { 307 | filters.push(`invert(var(${invertVar}, 0))`); 308 | } 309 | 310 | if (getHasAnimationProperty("backdrop-opacity", properties)) { 311 | filters.push(`opacity(var(${opacityVar}, 1))`); 312 | } 313 | 314 | if (getHasAnimationProperty("backdrop-saturate", properties)) { 315 | filters.push(`saturate(var(${saturateVar}, 1))`); 316 | } 317 | 318 | if (getHasAnimationProperty("backdrop-sepia", properties)) { 319 | filters.push(`sepia(var(${sepiaVar}, 0))`); 320 | } 321 | 322 | return `backdrop-filter: ${filters.join(" ")};`; 323 | } 324 | 325 | function getDoesNeedVariables(property: keyof AnimatableProperties) { 326 | return getIsTransformProperty(property) || getIsFilterProperty(property) || getIsBackdropFilterProperty(property); 327 | } 328 | 329 | function getAnimationPropertyStyleString( 330 | property: T, 331 | value: AnimatableProperties[T], 332 | progress: number, 333 | properties: PropertiesSteps, 334 | ) { 335 | if (getIsTransformProperty(property)) return getTransformAnimationStyleString(progress, properties); 336 | if (getIsFilterProperty(property)) return getFilterAnimationStyleString(progress, properties); 337 | if (getIsBackdropFilterProperty(property)) return getBackdropFilterAnimationStyleString(progress, properties); 338 | 339 | return `${property}: ${value};`; 340 | } 341 | 342 | function getAnimationPropertyKeyframes( 343 | property: T, 344 | steps: PropertyAnimationSteps, 345 | properties: PropertiesSteps, 346 | ) { 347 | return steps.map((step, index) => { 348 | const progress = index / (steps.length - 1); 349 | const percentage = getKeyframePercentageLabel(progress); 350 | 351 | return { 352 | percentageLabel: percentage, 353 | style: getAnimationPropertyStyleString(property, step, progress, properties), 354 | }; 355 | }); 356 | } 357 | 358 | function getAnimationKeyframesString(properties: PropertiesSteps) { 359 | const keyframes = Object.entries(properties) 360 | .map(([property, steps]) => { 361 | return getAnimationPropertyKeyframes(property as keyof AnimatableProperties, steps, properties); 362 | }) 363 | .flat(); 364 | 365 | const keyframesMap = new Map>(); 366 | 367 | for (const keyframe of keyframes) { 368 | let styles = keyframesMap.get(keyframe.percentageLabel); 369 | 370 | if (!styles) { 371 | styles = new Set(); 372 | } 373 | 374 | styles.add(keyframe.style); 375 | keyframesMap.set(keyframe.percentageLabel, styles); 376 | } 377 | 378 | return Array.from(keyframesMap.entries()).map(([percentage, styles]) => { 379 | return `${percentage} { ${Array.from(styles).join("")} } `; 380 | }); 381 | } 382 | 383 | const config = new ComposerConfig({ 384 | duration: "150ms", 385 | easing: "ease-in-out", 386 | }); 387 | 388 | export class AnimationComposer extends Composer { 389 | property

(property: P, steps: PropertyAnimationSteps) { 390 | const currentProperties = this.getConfig(config).properties ?? {}; 391 | 392 | return this.updateConfig(config, { properties: { ...currentProperties, [property]: steps } }); 393 | } 394 | 395 | duration(duration: Length) { 396 | return this.updateConfig(config, { duration: addUnit(duration, "ms") }); 397 | } 398 | 399 | delay(delay: Length) { 400 | return this.addStyle({ animationDelay: addUnit(delay, "ms") }); 401 | } 402 | 403 | get fadeIn() { 404 | return this.property("opacity", [0, 1]); 405 | } 406 | 407 | get fadeOut() { 408 | return this.property("opacity", [1, 0]); 409 | } 410 | 411 | slideUpFromBottom(by: Length) { 412 | return this.property("transform-y", [resolveMaybeBaseValue(by), "0"]); 413 | } 414 | 415 | slideDownFromTop(by: Length) { 416 | return this.property("transform-y", [`-${resolveMaybeBaseValue(by)}`, "0"]); 417 | } 418 | 419 | zoomIn(scale: number) { 420 | return this.property("transform-scale", [scale, 1]); 421 | } 422 | 423 | zoomOut(scale: number) { 424 | return this.property("transform-scale", [1, scale]); 425 | } 426 | 427 | easing(easing: Property.AnimationTimingFunction) { 428 | return this.updateConfig(config, { easing }); 429 | } 430 | 431 | slideLeftFromRight(by: Length) { 432 | return this.property("transform-x", [`-${resolveMaybeBaseValue(by)}`, "0"]); 433 | } 434 | 435 | slideRightFromLeft(by: Length) { 436 | return this.property("transform-x", [resolveMaybeBaseValue(by), "0"]); 437 | } 438 | 439 | fillMode(mode: Property.AnimationFillMode) { 440 | return this.addStyle({ animationFillMode: mode }); 441 | } 442 | 443 | iterationCount(count: Property.AnimationIterationCount) { 444 | return this.addStyle({ animationIterationCount: count }); 445 | } 446 | 447 | rotate(angles: Length[]) { 448 | return this.property("transform-rotate", angles); 449 | } 450 | 451 | x(steps: Length[]) { 452 | return this.property("transform-x", resolveMaybeBaseValues(steps)); 453 | } 454 | 455 | y(steps: Length[]) { 456 | return this.property("transform-y", resolveMaybeBaseValues(steps)); 457 | } 458 | 459 | scale(steps: number[]) { 460 | return this.property("transform-scale", steps); 461 | } 462 | 463 | blur(steps: Length[]) { 464 | return this.property( 465 | "filter-blur", 466 | steps.map((s) => addUnit(s, "px")), 467 | ); 468 | } 469 | 470 | opacity(steps: Length[]) { 471 | return this.property("opacity", steps); 472 | } 473 | 474 | get infinite() { 475 | return this.addStyle({ animationIterationCount: "infinite" }); 476 | } 477 | 478 | get spin() { 479 | return this.rotate(["0deg", "360deg"]).infinite.easing("linear").duration("2s"); 480 | } 481 | 482 | get transformStyle() { 483 | return ""; 484 | } 485 | 486 | compile() { 487 | if (getHasValue(this.compileCache)) return this.compileCache; 488 | 489 | const currentConfig = this.getConfig(config); 490 | 491 | if (!currentConfig.properties) return super.compile(); 492 | 493 | const variables = getPropertiesAnimationVariables(currentConfig.properties); 494 | const keyframesString = getAnimationKeyframesString(currentConfig.properties); 495 | 496 | // prettier-ignore 497 | const animation = keyframes`${keyframesString}`; 498 | 499 | const willChangeProperties = getWillChangeProperties(currentConfig.properties); 500 | 501 | // prettier-ignore 502 | const animationName = css`animation-name: ${animation};`; 503 | 504 | // const rule = simplifyRule(css` 505 | // animation-name: ${animation}; 506 | 507 | // ${{ 508 | // animationDuration: addUnit(currentConfig.duration, "ms"), 509 | // animationTimingFunction: currentConfig.easing, 510 | // willChange: willChangeProperties?.join(", ") ?? undefined, 511 | // ...variables, 512 | // }} 513 | // `); 514 | 515 | return super.compile([ 516 | animationName, 517 | `animation-duration: ${addUnit(currentConfig.duration, "ms")};`, 518 | `animation-timing-function: ${currentConfig.easing};`, 519 | willChangeProperties ? `will-change: ${willChangeProperties.join(", ")};` : undefined, 520 | ...Object.entries(variables).map(([key, value]) => `${key}: ${value};`), 521 | ]); 522 | } 523 | } 524 | 525 | export const $animation = composer(AnimationComposer); 526 | -------------------------------------------------------------------------------- /stylings/src/ColorComposer.ts: -------------------------------------------------------------------------------- 1 | import { Composer, composer } from "./Composer"; 2 | import { blendColors, getColorBorderColor, getHighlightedColor, isColorDark, setColorOpacity } from "./utils/color"; 3 | 4 | import { ComposerConfig } from "./ComposerConfig"; 5 | import { getHasValue } from "./utils/maybeValue"; 6 | import { isDefined } from "./utils"; 7 | import { memoizeFn } from "./utils/memoize"; 8 | 9 | interface ColorsInput { 10 | color: string; 11 | foreground?: string; 12 | hoverMultiplier?: number; 13 | border?: string; 14 | hover?: string; 15 | active?: string; 16 | } 17 | 18 | export interface ColorConfig extends ColorsInput { 19 | outputType?: "inline" | "background" | "color" | "border" | "outline" | "fill"; 20 | isBackgroundWithBorder?: boolean; 21 | interactive?: boolean; 22 | } 23 | 24 | const config = new ComposerConfig({ 25 | color: "#000000", 26 | }); 27 | 28 | const hovers = [":hover", ".hover"]; 29 | const svgActives = [":active", ".active"]; 30 | const actives = [...svgActives, ":not(button):not(div):focus"]; 31 | 32 | const HOVER_SELECTOR = hovers.map((hover) => `&${hover}`).join(", "); 33 | const ACTIVE_SELECTOR = actives.map((active) => `&${active}`).join(", "); 34 | 35 | function getColorForeground(config: ColorConfig) { 36 | if (isColorDark(config.color)) { 37 | return "#ffffff"; 38 | } 39 | 40 | return "#000000"; 41 | } 42 | 43 | function getColorHoverColor(config: ColorConfig) { 44 | const hoverMultiplier = config.hoverMultiplier ?? 1; 45 | if (isDefined(config.hover)) { 46 | return config.hover; 47 | } 48 | 49 | return blendColors(config.color, config.foreground ?? getColorForeground(config), 0.125 * hoverMultiplier); 50 | } 51 | 52 | function getColorActiveColor(config: ColorConfig) { 53 | const hoverMultiplier = config.hoverMultiplier ?? 1; 54 | 55 | if (isDefined(config.active)) { 56 | return config.active; 57 | } 58 | 59 | return blendColors(config.color, config.foreground ?? getColorForeground(config), 0.175 * hoverMultiplier); 60 | } 61 | 62 | function getColorStyles(config: ColorConfig) { 63 | if (!isDefined(config.outputType) || config.outputType === "inline") { 64 | return config.color; 65 | } 66 | 67 | const styles: string[] = []; 68 | 69 | switch (config.outputType) { 70 | case "background": 71 | styles.push(`background-color: ${config.color}; --background-color: ${config.color};`); 72 | if (config.foreground) { 73 | styles.push(`color: ${config.foreground}; --color: ${config.foreground};`); 74 | } 75 | break; 76 | case "color": 77 | styles.push(`color: ${config.color}; --color: ${config.color};`); 78 | break; 79 | case "border": 80 | styles.push(`border-color: ${config.color}; --border-color: ${config.color};`); 81 | break; 82 | case "outline": 83 | styles.push(`outline-color: ${config.color}; --outline-color: ${config.color};`); 84 | break; 85 | case "fill": 86 | styles.push(`fill: ${config.color}; --fill-color: ${config.color};`); 87 | if (config.foreground) { 88 | styles.push(`color: ${config.foreground}; --color: ${config.foreground};`); 89 | } 90 | break; 91 | } 92 | 93 | if (config.isBackgroundWithBorder) { 94 | styles.push( 95 | `border: 1px solid ${getColorBorderColor(config.color)}; --border-color: ${getColorBorderColor(config.color)};`, 96 | ); 97 | } 98 | 99 | if (!config.interactive) { 100 | return styles.join(";"); 101 | } 102 | 103 | styles.push( 104 | `${HOVER_SELECTOR} { ${getColorStyles({ 105 | ...config, 106 | color: getColorHoverColor(config), 107 | interactive: false, 108 | })} }`, 109 | ); 110 | 111 | styles.push( 112 | `${ACTIVE_SELECTOR} { ${getColorStyles({ 113 | ...config, 114 | color: getColorActiveColor(config), 115 | interactive: false, 116 | })} }`, 117 | ); 118 | 119 | return styles; 120 | } 121 | 122 | export class ColorComposer extends Composer { 123 | define(value: ColorsInput) { 124 | return this.updateConfig(config, value); 125 | } 126 | 127 | color(value: string) { 128 | return this.updateConfig(config, { color: value }); 129 | } 130 | 131 | opacity(value: number) { 132 | const currentConfig = this.getConfig(config); 133 | 134 | return this.updateConfig(config, { color: setColorOpacity(currentConfig.color, value) }); 135 | } 136 | 137 | get secondary() { 138 | return this.opacity(0.55); 139 | } 140 | 141 | get tertiary() { 142 | return this.opacity(0.3); 143 | } 144 | 145 | get transparent() { 146 | return this.opacity(0); 147 | } 148 | 149 | outputType(value: ColorConfig["outputType"]) { 150 | return this.updateConfig(config, { outputType: value }); 151 | } 152 | 153 | get asBg() { 154 | return this.outputType("background"); 155 | } 156 | 157 | get asColor() { 158 | return this.outputType("color"); 159 | } 160 | 161 | get asBorder() { 162 | return this.outputType("border"); 163 | } 164 | 165 | get asOutline() { 166 | return this.outputType("outline"); 167 | } 168 | 169 | get asFill() { 170 | return this.outputType("fill"); 171 | } 172 | 173 | get withBorder() { 174 | return this.updateConfig(config, { isBackgroundWithBorder: true }); 175 | } 176 | 177 | get interactive() { 178 | return this.updateConfig(config, { interactive: true }); 179 | } 180 | 181 | private changeColor(value: string) { 182 | const currentConfig = this.getConfig(config); 183 | 184 | return this.updateConfig(config, { 185 | color: value, 186 | border: currentConfig.border, 187 | foreground: currentConfig.foreground, 188 | }); 189 | } 190 | 191 | get hover() { 192 | const currentConfig = this.getConfig(config); 193 | 194 | return this.changeColor(getHighlightedColor(currentConfig.color)); 195 | } 196 | 197 | get active() { 198 | const currentConfig = this.getConfig(config); 199 | 200 | return this.changeColor(getHighlightedColor(currentConfig.color, 2)); 201 | } 202 | 203 | get muted() { 204 | const currentConfig = this.getConfig(config); 205 | 206 | return this.changeColor(getHighlightedColor(currentConfig.color, 0.33)); 207 | } 208 | 209 | highlight(ratio: number = 1) { 210 | const currentConfig = this.getConfig(config); 211 | 212 | return this.changeColor(getHighlightedColor(currentConfig.color, ratio)); 213 | } 214 | 215 | get foreground() { 216 | const currentConfig = this.getConfig(config); 217 | 218 | return this.updateConfig(config, { color: getColorForeground(currentConfig) }); 219 | } 220 | 221 | compile() { 222 | if (getHasValue(this.compileCache)) return this.compileCache; 223 | 224 | const currentConfig = this.getConfig(config); 225 | 226 | return super.compile(getColorStyles(currentConfig)); 227 | } 228 | } 229 | 230 | export const $color = memoizeFn( 231 | function color(color: ColorsInput) { 232 | return composer(ColorComposer).define(color); 233 | }, 234 | { mode: "hash" }, 235 | ); 236 | -------------------------------------------------------------------------------- /stylings/src/CommonComposer.ts: -------------------------------------------------------------------------------- 1 | import { Composer, composer } from "./Composer"; 2 | 3 | import { Length } from "./utils"; 4 | import type { Properties } from "csstype"; 5 | import { resolveMaybeBaseValue } from "./SizeComposer"; 6 | 7 | export class CommonComposer extends Composer { 8 | get disabled() { 9 | return this.addStyle([`opacity: 0.5;`, `pointer-events: none;`]); 10 | } 11 | 12 | get round() { 13 | return this.addStyle(`border-radius: 1000px;`); 14 | } 15 | 16 | get secondary() { 17 | return this.addStyle(`opacity: 0.5;`); 18 | } 19 | 20 | get tertiary() { 21 | return this.addStyle(`opacity: 0.25;`); 22 | } 23 | 24 | get quaternary() { 25 | return this.addStyle(`opacity: 0.125;`); 26 | } 27 | 28 | get notAllowed() { 29 | return this.addStyle([`cursor: not-allowed;`, `opacity: 0.5;`]); 30 | } 31 | 32 | get fullWidth() { 33 | return this.addStyle(`width: 100%;`); 34 | } 35 | 36 | get fullHeight() { 37 | return this.addStyle(`height: 100%;`); 38 | } 39 | 40 | width(width: Length) { 41 | return this.addStyle(`width: ${resolveMaybeBaseValue(width)};`); 42 | } 43 | 44 | height(height: Length) { 45 | return this.addStyle(`height: ${resolveMaybeBaseValue(height)};`); 46 | } 47 | 48 | get circle() { 49 | return this.addStyle(`border-radius: 1000px;`); 50 | } 51 | 52 | size(size: Length) { 53 | return this.width(size).height(size); 54 | } 55 | 56 | z(z: number) { 57 | return this.addStyle(`z-index: ${z};`); 58 | } 59 | 60 | get widthFull() { 61 | return this.width("100%"); 62 | } 63 | 64 | get heightFull() { 65 | return this.height("100%"); 66 | } 67 | 68 | get relative() { 69 | return this.addStyle(`position: relative;`); 70 | } 71 | 72 | get absolute() { 73 | return this.addStyle(`position: absolute;`); 74 | } 75 | 76 | get fixed() { 77 | return this.addStyle(`position: fixed;`); 78 | } 79 | 80 | get notSelectable() { 81 | return this.addStyle(`user-select: none;`); 82 | } 83 | 84 | cursor(cursor: Properties["cursor"]) { 85 | return this.addStyle(`cursor: ${cursor};`); 86 | } 87 | 88 | pt(py: Length) { 89 | return this.addStyle(`padding-top: ${resolveMaybeBaseValue(py)};`); 90 | } 91 | 92 | pb(py: Length) { 93 | return this.addStyle(`padding-bottom: ${resolveMaybeBaseValue(py)};`); 94 | } 95 | 96 | py(px: Length) { 97 | return this.pt(px).pb(px); 98 | } 99 | 100 | pl(px: Length) { 101 | return this.addStyle(`padding-left: ${resolveMaybeBaseValue(px)};`); 102 | } 103 | 104 | pr(px: Length) { 105 | return this.addStyle(`padding-right: ${resolveMaybeBaseValue(px)};`); 106 | } 107 | 108 | px(px: Length) { 109 | return this.pl(px).pr(px); 110 | } 111 | 112 | p(px: Length) { 113 | return this.py(px).px(px); 114 | } 115 | 116 | mt(mt: Length) { 117 | return this.addStyle(`margin-top: ${resolveMaybeBaseValue(mt)};`); 118 | } 119 | 120 | mb(mb: Length) { 121 | return this.addStyle(`margin-bottom: ${resolveMaybeBaseValue(mb)};`); 122 | } 123 | 124 | my(my: Length) { 125 | return this.mt(my).mb(my); 126 | } 127 | 128 | ml(ml: Length) { 129 | return this.addStyle(`margin-left: ${resolveMaybeBaseValue(ml)};`); 130 | } 131 | 132 | mr(mr: Length) { 133 | return this.addStyle(`margin-right: ${resolveMaybeBaseValue(mr)};`); 134 | } 135 | 136 | mx(mx: Length) { 137 | return this.ml(mx).mr(mx); 138 | } 139 | 140 | m(mx: Length) { 141 | return this.my(mx).mx(mx); 142 | } 143 | 144 | left(left: Length) { 145 | return this.addStyle(`left: ${resolveMaybeBaseValue(left)};`); 146 | } 147 | 148 | right(right: Length) { 149 | return this.addStyle(`right: ${resolveMaybeBaseValue(right)};`); 150 | } 151 | 152 | top(top: Length) { 153 | return this.addStyle(`top: ${resolveMaybeBaseValue(top)};`); 154 | } 155 | 156 | bottom(bottom: Length) { 157 | return this.addStyle(`bottom: ${resolveMaybeBaseValue(bottom)};`); 158 | } 159 | 160 | inset(inset: Length) { 161 | return this.top(inset).right(inset).bottom(inset).left(inset); 162 | } 163 | 164 | aspectRatio(ratio: number) { 165 | return this.addStyle(`aspect-ratio: ${ratio};`); 166 | } 167 | 168 | get square() { 169 | return this.aspectRatio(1); 170 | } 171 | 172 | maxWidth(maxWidth: Length) { 173 | return this.addStyle(`max-width: ${resolveMaybeBaseValue(maxWidth)};`); 174 | } 175 | 176 | maxHeight(maxHeight: Length) { 177 | return this.addStyle(`max-height: ${resolveMaybeBaseValue(maxHeight)};`); 178 | } 179 | 180 | minWidth(minWidth: Length) { 181 | return this.addStyle(`min-width: ${resolveMaybeBaseValue(minWidth)};`); 182 | } 183 | 184 | minHeight(minHeight: Length) { 185 | return this.addStyle(`min-height: ${resolveMaybeBaseValue(minHeight)};`); 186 | } 187 | 188 | x(x: Length) { 189 | return this.addStyle(`transform: translateX(${resolveMaybeBaseValue(x)});`); 190 | } 191 | 192 | y(y: Length) { 193 | return this.addStyle(`transform: translateY(${resolveMaybeBaseValue(y)});`); 194 | } 195 | 196 | get overflowHidden() { 197 | return this.addStyle(`overflow: hidden;`); 198 | } 199 | 200 | lineClamp(lines: number) { 201 | return this.addStyle({ 202 | overflow: "hidden", 203 | display: "-webkit-box", 204 | WebkitBoxOrient: "vertical", 205 | WebkitLineClamp: lines, 206 | }); 207 | } 208 | } 209 | 210 | export const $common = composer(CommonComposer); 211 | -------------------------------------------------------------------------------- /stylings/src/Composer.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, RuleSet } from "styled-components"; 2 | import { getHasValue, maybeValue } from "./utils/maybeValue"; 3 | 4 | import { ComposerConfig } from "./ComposerConfig"; 5 | import { DeepMap } from "./utils/map/DeepMap"; 6 | import { HashMap } from "./utils/map/HashMap"; 7 | import { MaybeUndefined } from "./utils/nullish"; 8 | import { compileComposerStyles } from "./compilation"; 9 | import { getObjectId } from "./utils/objectId"; 10 | import { isPrimitive } from "./utils/primitive"; 11 | import { memoizeFn } from "./utils/memoize"; 12 | 13 | const IS_COMPOSER = Symbol("isComposer"); 14 | 15 | export interface GetStylesProps { 16 | theme?: ThemeOrThemeProps; 17 | } 18 | export type ThemeOrThemeProps = GetStylesProps | object; 19 | 20 | export type GetStyles = (propsOrTheme?: ThemeOrThemeProps) => CompileResult; 21 | export type ComposerStyle = CSSProperties | string | Composer | RuleSet | Array; 22 | 23 | export type StyledComposer = T extends GetStyles ? T : T & GetStyles; 24 | export type AnyStyledComposer = StyledComposer; 25 | 26 | export type PickComposer = T extends StyledComposer ? U : never; 27 | 28 | export type CompileResult = string | string[] | RuleSet | null | Array; 29 | 30 | function getIsStyledComposer(value: C): value is StyledComposer { 31 | return typeof value === "function"; 32 | } 33 | 34 | type ConstructorOf = new (...args: any[]) => T; 35 | 36 | interface HolderFunction { 37 | (): void; 38 | rawComposer: Composer; 39 | } 40 | 41 | const composerHolderFunctionProxyHandler: ProxyHandler = { 42 | get(holderFunction, prop) { 43 | if (prop === "rawComposer") { 44 | return holderFunction.rawComposer; 45 | } 46 | 47 | return holderFunction.rawComposer[prop as keyof Composer]; 48 | }, 49 | set(holderFunction, prop, value) { 50 | // @ts-expect-error 51 | holderFunction.rawComposer[prop as keyof Composer] = value; 52 | 53 | return true; 54 | }, 55 | apply(holderFunction) { 56 | return holderFunction.rawComposer.compile(); 57 | }, 58 | getPrototypeOf(target) { 59 | return Object.getPrototypeOf(target.rawComposer); 60 | }, 61 | deleteProperty(target, prop) { 62 | return Reflect.deleteProperty(target.rawComposer, prop); 63 | }, 64 | has(target, prop) { 65 | return Reflect.has(target.rawComposer, prop); 66 | }, 67 | ownKeys(target) { 68 | return Reflect.ownKeys(target.rawComposer); 69 | }, 70 | getOwnPropertyDescriptor(target, prop) { 71 | return Reflect.getOwnPropertyDescriptor(target.rawComposer, prop); 72 | }, 73 | setPrototypeOf(target, proto) { 74 | return Reflect.setPrototypeOf(target.rawComposer, proto); 75 | }, 76 | isExtensible(target) { 77 | return Reflect.isExtensible(target.rawComposer); 78 | }, 79 | preventExtensions(target) { 80 | return Reflect.preventExtensions(target.rawComposer); 81 | }, 82 | }; 83 | 84 | export function getIsComposer(input: unknown): input is Composer { 85 | if (isPrimitive(input)) return false; 86 | 87 | return IS_COMPOSER in (input as object); 88 | } 89 | 90 | export function pickComposer(input: C): C { 91 | if (getIsStyledComposer(input)) { 92 | return input.rawComposer as C; 93 | } 94 | 95 | if (getIsComposer(input)) { 96 | return input; 97 | } 98 | 99 | throw new Error("Invalid composer"); 100 | } 101 | 102 | let isCreatingWithComposerFunction = false; 103 | 104 | const warnAboutCreatingInstanceDirectly = memoizeFn((constructor: typeof Composer) => { 105 | const name = constructor.name; 106 | console.warn( 107 | `Seems you are creating ${name} composer using "const instance = new ${name}()". Use "const instance = composer(${name})" instead.`, 108 | ); 109 | }); 110 | 111 | const EMPTY_CONFIGS = new Map(); 112 | const EMPTY_STYLES: ComposerStyle[] = []; 113 | 114 | function createCompilerCallProxy(composer: C) { 115 | if (typeof composer === "function") { 116 | return composer; 117 | } 118 | const compileStyles: HolderFunction = () => {}; 119 | 120 | compileStyles.rawComposer = composer; 121 | 122 | return new Proxy(compileStyles, composerHolderFunctionProxyHandler) as StyledComposer; 123 | } 124 | 125 | export class Composer { 126 | readonly styles: ComposerStyle[] = EMPTY_STYLES; 127 | readonly configs: Map = EMPTY_CONFIGS; 128 | 129 | readonly [IS_COMPOSER] = true; 130 | 131 | constructor() { 132 | if (!isCreatingWithComposerFunction) { 133 | warnAboutCreatingInstanceDirectly(this.constructor as typeof Composer); 134 | } 135 | } 136 | 137 | get rawComposer() { 138 | return this; 139 | } 140 | 141 | clone( 142 | this: T, 143 | styles: ComposerStyle[], 144 | configs: Map, 145 | ): StyledComposer { 146 | try { 147 | isCreatingWithComposerFunction = true; 148 | 149 | const newComposer = new (this.constructor as ConstructorOf)() as StyledComposer; 150 | 151 | // @ts-expect-error 152 | newComposer.styles = styles; 153 | // @ts-expect-error 154 | newComposer.configs = configs; 155 | 156 | return newComposer; 157 | } finally { 158 | isCreatingWithComposerFunction = false; 159 | } 160 | } 161 | 162 | private updateConfigCache = new DeepMap(HashMap); 163 | 164 | updateConfig(this: T, config: ComposerConfig, changes: Partial): StyledComposer { 165 | const key = [config, changes]; 166 | 167 | let clone = this.updateConfigCache.get(key) as StyledComposer | undefined; 168 | 169 | if (clone) { 170 | return clone; 171 | } 172 | 173 | const existingConfig = this.getConfig(config); 174 | 175 | if (!existingConfig) { 176 | clone = this.setConfig(config, { ...config.defaultConfig, ...changes }); 177 | } else { 178 | clone = this.setConfig(config, { ...existingConfig, ...changes }); 179 | } 180 | 181 | clone = createCompilerCallProxy(clone); 182 | 183 | this.updateConfigCache.set(key, clone); 184 | 185 | return clone; 186 | } 187 | 188 | private setConfig(this: T, config: ComposerConfig, value: C): StyledComposer { 189 | const configs = new Map(this.configs); 190 | 191 | configs.set(config, value); 192 | 193 | return this.clone(this.styles, configs); 194 | } 195 | 196 | getConfig(config: ComposerConfig): C { 197 | const existingConfig = this.configs?.get(config) as MaybeUndefined; 198 | 199 | return existingConfig ?? config.defaultConfig; 200 | } 201 | 202 | private reuseStyleMap = new HashMap>(); 203 | 204 | addStyle(this: T, style: ComposerStyle): StyledComposer { 205 | let clone = this.reuseStyleMap.get(style) as StyledComposer | undefined; 206 | 207 | if (clone) { 208 | return clone; 209 | } 210 | 211 | clone = createCompilerCallProxy(this.clone([...this.styles, style], this.configs)); 212 | 213 | this.reuseStyleMap.set(style, clone); 214 | 215 | return clone; 216 | } 217 | 218 | init() { 219 | return this; 220 | } 221 | 222 | protected compileCache = maybeValue(); 223 | 224 | compile(addedStyles?: ComposerStyle): CompileResult { 225 | if (getHasValue(this.compileCache)) { 226 | return this.compileCache; 227 | } 228 | 229 | this.compileCache = compileComposerStyles(addedStyles ? [...this.styles, addedStyles] : this.styles); 230 | 231 | return this.compileCache; 232 | } 233 | } 234 | 235 | export function composer(Composer: ConstructorOf): StyledComposer { 236 | try { 237 | isCreatingWithComposerFunction = true; 238 | const composer = new Composer(); 239 | 240 | return createCompilerCallProxy(composer.init()); 241 | } finally { 242 | isCreatingWithComposerFunction = false; 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /stylings/src/ComposerConfig.ts: -------------------------------------------------------------------------------- 1 | import { JSONObject } from "./utils/json"; 2 | 3 | interface ComposerConfigOptions { 4 | cache?: boolean; 5 | } 6 | 7 | export class ComposerConfig { 8 | constructor( 9 | readonly defaultConfig: T, 10 | readonly options: ComposerConfigOptions = { cache: true }, 11 | ) {} 12 | } 13 | 14 | export function composerConfig(defaultConfig: T) { 15 | return new ComposerConfig(defaultConfig); 16 | } 17 | -------------------------------------------------------------------------------- /stylings/src/FlexComposer.ts: -------------------------------------------------------------------------------- 1 | import { Composer, composer } from "./Composer"; 2 | 3 | import { CSSProperties } from "styled-components"; 4 | import { convertToRem } from "./utils/convertUnits"; 5 | 6 | export class FlexComposer extends Composer { 7 | init() { 8 | return this.addStyle(`display: flex;`); 9 | } 10 | 11 | direction(value: CSSProperties["flexDirection"]) { 12 | return this.addStyle(`flex-direction: ${value};`); 13 | } 14 | 15 | get row() { 16 | return this.addStyle(`flex-direction: row;`); 17 | } 18 | 19 | get column() { 20 | return this.addStyle(`flex-direction: column;`); 21 | } 22 | 23 | /** 24 | * @alias row 25 | */ 26 | get horizontal() { 27 | return this.row; 28 | } 29 | 30 | /** 31 | * @alias column 32 | */ 33 | get vertical() { 34 | return this.column; 35 | } 36 | 37 | /** 38 | * @alias row 39 | */ 40 | get x() { 41 | return this.row; 42 | } 43 | 44 | /** 45 | * @alias column 46 | */ 47 | get y() { 48 | return this.column; 49 | } 50 | 51 | gap(value: number | string = 1) { 52 | if (typeof value === "string") { 53 | return this.addStyle(`gap: ${value};`); 54 | } 55 | 56 | return this.addStyle(`gap: ${convertToRem(value, "level")}rem;`); 57 | } 58 | 59 | align(value: CSSProperties["alignItems"]) { 60 | return this.addStyle(`align-items: ${value};`); 61 | } 62 | 63 | get alignCenter() { 64 | return this.addStyle(`align-items: center;`); 65 | } 66 | 67 | get alignStart() { 68 | return this.addStyle(`align-items: flex-start;`); 69 | } 70 | 71 | get alignEnd() { 72 | return this.addStyle(`align-items: flex-end;`); 73 | } 74 | 75 | get alignStretch() { 76 | return this.addStyle(`align-items: stretch;`); 77 | } 78 | 79 | get alignBaseline() { 80 | return this.addStyle(`align-items: baseline;`); 81 | } 82 | 83 | justify(value: CSSProperties["justifyContent"]) { 84 | return this.addStyle(`justify-content: ${value};`); 85 | } 86 | 87 | get justifyCenter() { 88 | return this.addStyle(`justify-content: center;`); 89 | } 90 | 91 | get justifyStart() { 92 | return this.addStyle(`justify-content: flex-start;`); 93 | } 94 | 95 | get justifyEnd() { 96 | return this.addStyle(`justify-content: flex-end;`); 97 | } 98 | 99 | get justifyBetween() { 100 | return this.addStyle(`justify-content: space-between;`); 101 | } 102 | 103 | get justifyAround() { 104 | return this.addStyle(`justify-content: space-around;`); 105 | } 106 | 107 | get justifyEvenly() { 108 | return this.addStyle(`justify-content: space-evenly;`); 109 | } 110 | 111 | get center() { 112 | return this.alignCenter.justifyCenter; 113 | } 114 | 115 | get reverse() { 116 | return this.addStyle(`flex-direction: row-reverse;`); 117 | } 118 | 119 | get wrap() { 120 | return this.addStyle(`flex-wrap: wrap;`); 121 | } 122 | 123 | get inline() { 124 | return this.addStyle(`display: inline-flex;`); 125 | } 126 | } 127 | 128 | export const $flex = composer(FlexComposer); 129 | -------------------------------------------------------------------------------- /stylings/src/FontComposer.ts: -------------------------------------------------------------------------------- 1 | import { Composer, composer } from "./Composer"; 2 | import { Length, addUnit } from "./utils"; 3 | 4 | import { Properties } from "csstype"; 5 | 6 | export class FontComposer extends Composer { 7 | family(value: Properties["fontFamily"]) { 8 | return this.addStyle(`font-family: ${value};`); 9 | } 10 | 11 | size(value: Length) { 12 | return this.addStyle(`font-size: ${addUnit(value, "em")};`); 13 | } 14 | 15 | weight(value: Properties["fontWeight"]) { 16 | return this.addStyle(`font-weight: ${value};`); 17 | } 18 | 19 | lineHeight(value: Length) { 20 | return this.addStyle(`line-height: ${addUnit(value, "em")};`); 21 | } 22 | 23 | get copyLineHeight() { 24 | return this.lineHeight(1.5); 25 | } 26 | 27 | get headingLineHeight() { 28 | return this.lineHeight(1.25); 29 | } 30 | 31 | get balance() { 32 | return this.addStyle(`text-wrap: balance;`); 33 | } 34 | 35 | get uppercase() { 36 | return this.addStyle(`text-transform: uppercase;`); 37 | } 38 | 39 | get lowercase() { 40 | return this.addStyle(`text-transform: lowercase;`); 41 | } 42 | 43 | get capitalize() { 44 | return this.addStyle(`text-transform: capitalize;`); 45 | } 46 | 47 | get underline() { 48 | return this.addStyle(`text-decoration: underline;`); 49 | } 50 | 51 | get left() { 52 | return this.addStyle(`text-align: left;`); 53 | } 54 | 55 | get center() { 56 | return this.addStyle(`text-align: center;`); 57 | } 58 | 59 | get right() { 60 | return this.addStyle(`text-align: right;`); 61 | } 62 | 63 | get ellipsis() { 64 | return this.addStyle(`text-overflow: ellipsis; white-space: nowrap; overflow: hidden;`); 65 | } 66 | 67 | get resetLineHeight() { 68 | return this.lineHeight(1); 69 | } 70 | 71 | maxLines(value: number) { 72 | return this.addStyle({ 73 | WebkitLineClamp: value, 74 | WebkitBoxOrient: "vertical", 75 | overflow: "hidden", 76 | display: "-webkit-box", 77 | }); 78 | } 79 | 80 | opacity(value: number) { 81 | return this.addStyle(`opacity: ${value};`); 82 | } 83 | 84 | get secondary() { 85 | return this.opacity(0.5); 86 | } 87 | 88 | get tertiary() { 89 | return this.opacity(0.3); 90 | } 91 | 92 | get w100() { 93 | return this.weight(100); 94 | } 95 | 96 | get w200() { 97 | return this.weight(200); 98 | } 99 | 100 | get w300() { 101 | return this.weight(300); 102 | } 103 | 104 | get w400() { 105 | return this.weight(400); 106 | } 107 | 108 | get w500() { 109 | return this.weight(500); 110 | } 111 | 112 | get normal() { 113 | return this.weight(400); 114 | } 115 | 116 | get w600() { 117 | return this.weight(600); 118 | } 119 | 120 | letterSpacing(value: Length) { 121 | return this.addStyle(`letter-spacing: ${addUnit(value, "em")};`); 122 | } 123 | 124 | get w700() { 125 | return this.weight(700); 126 | } 127 | 128 | get w800() { 129 | return this.weight(800); 130 | } 131 | 132 | get w900() { 133 | return this.weight(900); 134 | } 135 | 136 | get nowrap() { 137 | return this.addStyle(`white-space: nowrap;`); 138 | } 139 | 140 | get antialiased() { 141 | return this.addStyle(`-webkit-font-smoothing: antialiased;`); 142 | } 143 | } 144 | 145 | export const $font = composer(FontComposer); 146 | -------------------------------------------------------------------------------- /stylings/src/GridComposer.ts: -------------------------------------------------------------------------------- 1 | import { Composer, composer } from "./Composer"; 2 | 3 | import { CSSProperties } from "styled-components"; 4 | import type { Property } from "csstype"; 5 | import { convertToRem } from "./utils/convertUnits"; 6 | 7 | export class GridComposer extends Composer { 8 | init() { 9 | return this.addStyle(`display: grid;`); 10 | } 11 | 12 | columns(value: CSSProperties["gridTemplateColumns"]) { 13 | return this.addStyle(`grid-template-columns: ${value};`); 14 | } 15 | 16 | rows(value: CSSProperties["gridTemplateRows"]) { 17 | return this.addStyle(`grid-template-rows: ${value};`); 18 | } 19 | 20 | gap(value: number = 1) { 21 | return this.addStyle(`gap: ${convertToRem(value, "level")}rem;`); 22 | } 23 | 24 | alignItems(value: Property.AlignItems) { 25 | return this.addStyle(`align-items: ${value};`); 26 | } 27 | 28 | get alignItemsCenter() { 29 | return this.alignItems("center"); 30 | } 31 | 32 | get alignItemsStart() { 33 | return this.alignItems("start"); 34 | } 35 | 36 | get alignItemsEnd() { 37 | return this.alignItems("end"); 38 | } 39 | 40 | get alignItemsStretch() { 41 | return this.alignItems("stretch"); 42 | } 43 | 44 | justifyItems(value: Property.JustifyItems) { 45 | return this.addStyle(`justify-items: ${value};`); 46 | } 47 | 48 | get justifyItemsCenter() { 49 | return this.justifyItems("center"); 50 | } 51 | 52 | get justifyItemsStart() { 53 | return this.justifyItems("start"); 54 | } 55 | 56 | get justifyItemsEnd() { 57 | return this.justifyItems("end"); 58 | } 59 | 60 | get justifyItemsStretch() { 61 | return this.justifyItems("stretch"); 62 | } 63 | 64 | alignContent(value: Property.AlignContent) { 65 | return this.addStyle(`align-content: ${value};`); 66 | } 67 | 68 | get alignContentCenter() { 69 | return this.alignContent("center"); 70 | } 71 | 72 | get alignContentStart() { 73 | return this.alignContent("start"); 74 | } 75 | 76 | get alignContentEnd() { 77 | return this.alignContent("end"); 78 | } 79 | 80 | get alignContentStretch() { 81 | return this.alignContent("stretch"); 82 | } 83 | 84 | get alignContentBetween() { 85 | return this.alignContent("space-between"); 86 | } 87 | 88 | get alignContentAround() { 89 | return this.alignContent("space-around"); 90 | } 91 | 92 | get alignContentEvenly() { 93 | return this.alignContent("space-evenly"); 94 | } 95 | 96 | justifyContent(value: Property.JustifyContent) { 97 | return this.addStyle(`justify-content: ${value};`); 98 | } 99 | 100 | get justifyContentCenter() { 101 | return this.justifyContent("center"); 102 | } 103 | 104 | get justifyContentStart() { 105 | return this.justifyContent("start"); 106 | } 107 | 108 | get justifyContentEnd() { 109 | return this.justifyContent("end"); 110 | } 111 | 112 | get justifyContentStretch() { 113 | return this.justifyContent("stretch"); 114 | } 115 | 116 | get justifyContentBetween() { 117 | return this.justifyContent("space-between"); 118 | } 119 | 120 | get justifyContentAround() { 121 | return this.justifyContent("space-around"); 122 | } 123 | 124 | get justifyContentEvenly() { 125 | return this.justifyContent("space-evenly"); 126 | } 127 | 128 | autoFlow(value: Property.GridAutoFlow) { 129 | return this.addStyle(`grid-auto-flow: ${value};`); 130 | } 131 | 132 | get flowRow() { 133 | return this.autoFlow("row"); 134 | } 135 | 136 | get flowColumn() { 137 | return this.autoFlow("column"); 138 | } 139 | 140 | get flowRowDense() { 141 | return this.autoFlow("row dense"); 142 | } 143 | 144 | get flowColumnDense() { 145 | return this.autoFlow("column dense"); 146 | } 147 | 148 | get center() { 149 | return this.alignItemsCenter.justifyItemsCenter; 150 | } 151 | 152 | get inline() { 153 | return this.addStyle(`display: inline-grid;`); 154 | } 155 | } 156 | 157 | export const $grid = composer(GridComposer); 158 | -------------------------------------------------------------------------------- /stylings/src/ShadowComposer.ts: -------------------------------------------------------------------------------- 1 | import { Composer, composer } from "./Composer"; 2 | 3 | import { ComposerConfig } from "./ComposerConfig"; 4 | import { getHasValue } from "./utils/maybeValue"; 5 | import { setColorOpacity } from "./utils/color"; 6 | 7 | interface ShadowConfig { 8 | x?: number; 9 | y?: number; 10 | blur?: number; 11 | color?: string; 12 | inset?: boolean; 13 | spread?: number; 14 | } 15 | 16 | const shadowConfig = new ComposerConfig({}); 17 | 18 | export class ShadowComposer extends Composer { 19 | x(value: number) { 20 | return this.updateConfig(shadowConfig, { x: value }); 21 | } 22 | 23 | y(value: number) { 24 | return this.updateConfig(shadowConfig, { y: value }); 25 | } 26 | 27 | blur(value: number) { 28 | return this.updateConfig(shadowConfig, { blur: value }); 29 | } 30 | 31 | color(value: string) { 32 | return this.updateConfig(shadowConfig, { color: value }); 33 | } 34 | 35 | inset(value: boolean) { 36 | return this.updateConfig(shadowConfig, { inset: value }); 37 | } 38 | 39 | spread(value: number) { 40 | return this.updateConfig(shadowConfig, { spread: value }); 41 | } 42 | 43 | opacity(value: number) { 44 | const color = this.getConfig(shadowConfig).color; 45 | 46 | if (!color) { 47 | console.warn("To set shadow opacity, you must first set a color"); 48 | return this; 49 | } 50 | 51 | return this.updateConfig(shadowConfig, { color: setColorOpacity(color, value) }); 52 | } 53 | 54 | compile() { 55 | if (getHasValue(this.compileCache)) return this.compileCache; 56 | 57 | const { x, y, blur, color, inset, spread } = this.getConfig(shadowConfig); 58 | 59 | const shadowStyle = `box-shadow: ${x}px ${y}px ${blur}px ${spread}px ${color} ${inset ? "inset" : ""};`; 60 | 61 | return super.compile(shadowStyle); 62 | } 63 | } 64 | 65 | export const $shadow = composer(ShadowComposer); 66 | -------------------------------------------------------------------------------- /stylings/src/SizeComposer.ts: -------------------------------------------------------------------------------- 1 | import { Composer, composer } from "./Composer"; 2 | 3 | import { ComposerConfig } from "./ComposerConfig"; 4 | import { Length } from "./utils"; 5 | import { convertToRem } from "./utils/convertUnits"; 6 | 7 | type SizeOutputTarget = 8 | | "width" 9 | | "height" 10 | | "margin-x" 11 | | "margin-y" 12 | | "margin-top" 13 | | "margin-bottom" 14 | | "margin-left" 15 | | "margin-right" 16 | | "margin" 17 | | "padding-x" 18 | | "padding-y" 19 | | "padding-top" 20 | | "padding-bottom" 21 | | "padding-left" 22 | | "padding-right" 23 | | "padding" 24 | | "gap" 25 | | "min-width" 26 | | "min-height" 27 | | "max-width" 28 | | "max-height" 29 | | "transform-x" 30 | | "transform-y"; 31 | 32 | interface StyledSizeConfig { 33 | targets: SizeOutputTarget[] | "inline"; 34 | value: Length; 35 | } 36 | 37 | const config = new ComposerConfig({ 38 | targets: "inline", 39 | value: 0, 40 | }); 41 | 42 | export function resolveMaybeBaseValue(value: Length) { 43 | if (typeof value === "number") { 44 | return `${value / 4}rem`; 45 | } 46 | 47 | return value; 48 | } 49 | 50 | export function resolveMaybeBaseValues(values: Length[]) { 51 | return values.map(resolveMaybeBaseValue); 52 | } 53 | 54 | export function resolveSizeValue(value: Length) { 55 | if (typeof value === "number") { 56 | return `${value}rem`; 57 | } 58 | 59 | return value; 60 | } 61 | 62 | export function resolveSizeValues(values: Length[]) { 63 | return values.map(resolveSizeValue); 64 | } 65 | 66 | export class SizeComposer extends Composer { 67 | private get resolvedSize() { 68 | return resolveSizeValue(this.getConfig(config).value); 69 | } 70 | 71 | private setValue(value: Length) { 72 | return this.updateConfig(config, { value }); 73 | } 74 | 75 | base(value: number) { 76 | return this.setValue(convertToRem(value, "base") + "rem"); 77 | } 78 | 79 | rem(value: number) { 80 | return this.setValue(`${value}rem`); 81 | } 82 | 83 | level(level: number) { 84 | return this.setValue(convertToRem(level, "level") + "rem"); 85 | } 86 | 87 | px(value: number) { 88 | return this.setValue(`${value}px`); 89 | } 90 | 91 | em(value: number) { 92 | return this.setValue(`${value}em`); 93 | } 94 | 95 | get width() { 96 | return this.addStyle(`width: ${this.resolvedSize};`); 97 | } 98 | 99 | get height() { 100 | return this.addStyle(`height: ${this.resolvedSize};`); 101 | } 102 | 103 | get marginX() { 104 | return this.addStyle(`margin-left: ${this.resolvedSize}; margin-right: ${this.resolvedSize};`); 105 | } 106 | 107 | get marginY() { 108 | return this.addStyle(`margin-top: ${this.resolvedSize}; margin-bottom: ${this.resolvedSize};`); 109 | } 110 | 111 | get marginTop() { 112 | return this.addStyle(`margin-top: ${this.resolvedSize};`); 113 | } 114 | 115 | get marginBottom() { 116 | return this.addStyle(`margin-bottom: ${this.resolvedSize};`); 117 | } 118 | 119 | get marginLeft() { 120 | return this.addStyle(`margin-left: ${this.resolvedSize};`); 121 | } 122 | 123 | get marginRight() { 124 | return this.addStyle(`margin-right: ${this.resolvedSize};`); 125 | } 126 | 127 | get margin() { 128 | return this.addStyle([ 129 | `margin-top: ${this.resolvedSize};`, 130 | `margin-right: ${this.resolvedSize};`, 131 | `margin-bottom: ${this.resolvedSize};`, 132 | `margin-left: ${this.resolvedSize};`, 133 | ]); 134 | } 135 | 136 | get paddingX() { 137 | return this.addStyle(`padding-left: ${this.resolvedSize}; padding-right: ${this.resolvedSize};`); 138 | } 139 | 140 | get paddingY() { 141 | return this.addStyle(`padding-top: ${this.resolvedSize}; padding-bottom: ${this.resolvedSize};`); 142 | } 143 | 144 | get paddingTop() { 145 | return this.addStyle(`padding-top: ${this.resolvedSize};`); 146 | } 147 | 148 | get paddingBottom() { 149 | return this.addStyle(`padding-bottom: ${this.resolvedSize};`); 150 | } 151 | 152 | get paddingLeft() { 153 | return this.addStyle(`padding-left: ${this.resolvedSize};`); 154 | } 155 | 156 | get paddingRight() { 157 | return this.addStyle(`padding-right: ${this.resolvedSize};`); 158 | } 159 | 160 | get padding() { 161 | return this.addStyle([ 162 | `padding-top: ${this.resolvedSize};`, 163 | `padding-right: ${this.resolvedSize};`, 164 | `padding-bottom: ${this.resolvedSize};`, 165 | `padding-left: ${this.resolvedSize};`, 166 | ]); 167 | } 168 | 169 | get gap() { 170 | return this.addStyle(`gap: ${this.resolvedSize};`); 171 | } 172 | 173 | get size() { 174 | return this.width.height; 175 | } 176 | 177 | get minSize() { 178 | return this.minWidth.minHeight; 179 | } 180 | 181 | get maxSize() { 182 | return this.maxWidth.maxHeight; 183 | } 184 | 185 | get minWidth() { 186 | return this.addStyle(`min-width: ${this.resolvedSize};`); 187 | } 188 | 189 | get maxWidth() { 190 | return this.addStyle(`max-width: ${this.resolvedSize};`); 191 | } 192 | 193 | get minHeight() { 194 | return this.addStyle(`min-height: ${this.resolvedSize};`); 195 | } 196 | 197 | get maxHeight() { 198 | return this.addStyle(`max-height: ${this.resolvedSize};`); 199 | } 200 | 201 | get transformX() { 202 | return this.addStyle(`transform: translateX(${this.resolvedSize});`); 203 | } 204 | 205 | get transformY() { 206 | return this.addStyle(`transform: translateY(${this.resolvedSize});`); 207 | } 208 | 209 | get transformXY() { 210 | const resolvedSize = this.resolvedSize; 211 | 212 | return this.addStyle(`transform: translate(${resolvedSize}, ${resolvedSize});`); 213 | } 214 | } 215 | 216 | export const $size = composer(SizeComposer); 217 | -------------------------------------------------------------------------------- /stylings/src/SurfaceComposer.ts: -------------------------------------------------------------------------------- 1 | import { FlexComposer } from "./FlexComposer"; 2 | import { composer } from "./Composer"; 3 | import { composerConfig } from "./ComposerConfig"; 4 | import { memoizeFn } from "./utils/memoize"; 5 | import { resolveSizeValue } from "./SizeComposer"; 6 | 7 | interface SizingBoxConfig { 8 | paddingX?: number; 9 | paddingY?: number; 10 | radius?: number; 11 | height?: number; 12 | width?: number; 13 | } 14 | 15 | const config = composerConfig({}); 16 | 17 | export class FrameComposer extends FlexComposer { 18 | getStyles() { 19 | return null; 20 | } 21 | 22 | define(value: SizingBoxConfig) { 23 | return this.updateConfig(config, value as Partial); 24 | } 25 | 26 | get padding() { 27 | const { paddingX = 0, paddingY = 0 } = this.getConfig(config); 28 | 29 | return this.addStyle({ 30 | paddingLeft: resolveSizeValue(paddingX), 31 | paddingRight: resolveSizeValue(paddingX), 32 | paddingTop: resolveSizeValue(paddingY), 33 | paddingBottom: resolveSizeValue(paddingY), 34 | }); 35 | } 36 | 37 | get paddingX() { 38 | const { paddingX = 0 } = this.getConfig(config); 39 | 40 | return this.addStyle({ 41 | paddingLeft: resolveSizeValue(paddingX), 42 | paddingRight: resolveSizeValue(paddingX), 43 | }); 44 | } 45 | 46 | get paddingY() { 47 | const { paddingY = 0 } = this.getConfig(config); 48 | 49 | return this.addStyle({ 50 | paddingTop: resolveSizeValue(paddingY), 51 | paddingBottom: resolveSizeValue(paddingY), 52 | }); 53 | } 54 | 55 | get radius() { 56 | const { radius = 0 } = this.getConfig(config); 57 | 58 | return this.addStyle({ borderRadius: resolveSizeValue(radius) }); 59 | } 60 | 61 | get height() { 62 | const { height = 0 } = this.getConfig(config); 63 | 64 | return this.addStyle({ height: resolveSizeValue(height) }); 65 | } 66 | 67 | get width() { 68 | const { width = 0 } = this.getConfig(config); 69 | 70 | return this.addStyle({ width: resolveSizeValue(width) }); 71 | } 72 | 73 | get circle() { 74 | return this.define({ radius: 1000 }); 75 | } 76 | 77 | get size() { 78 | const { height = 0, width = 0 } = this.getConfig(config); 79 | 80 | return this.addStyle({ height: resolveSizeValue(height), width: resolveSizeValue(width) }); 81 | } 82 | 83 | get noPT() { 84 | return this.addStyle({ paddingTop: 0 }); 85 | } 86 | 87 | get noPB() { 88 | return this.addStyle({ paddingBottom: 0 }); 89 | } 90 | 91 | get noPL() { 92 | return this.addStyle({ paddingLeft: 0 }); 93 | } 94 | 95 | get noPR() { 96 | return this.addStyle({ paddingRight: 0 }); 97 | } 98 | } 99 | 100 | const $frameBase = composer(FrameComposer); 101 | 102 | export const $frame = memoizeFn( 103 | function frame(config: SizingBoxConfig) { 104 | return $frameBase.define(config); 105 | }, 106 | { mode: "hash" }, 107 | ); 108 | -------------------------------------------------------------------------------- /stylings/src/ThemeProvider.ts: -------------------------------------------------------------------------------- 1 | import { MaybeFalsy, isNotFalsy } from "./utils/nullish"; 2 | import { ReactNode, createElement, useMemo } from "react"; 3 | import { Theme, ThemeInput, ThemeVariant, composeThemeVariants } from "./theme"; 4 | 5 | import { ThemeProvider as StyledThemeProvider } from "styled-components"; 6 | import { useSameArray } from "./utils/hooks"; 7 | 8 | interface ThemeProviderProps { 9 | theme: Theme; 10 | activeVariants?: Array>>; 11 | children: ReactNode; 12 | } 13 | 14 | export function ThemeProvider(props: ThemeProviderProps) { 15 | const { theme, activeVariants } = props; 16 | 17 | let presentActiveVariants = activeVariants?.filter(isNotFalsy) ?? []; 18 | 19 | presentActiveVariants = useSameArray(presentActiveVariants); 20 | 21 | const resolvedTheme = useMemo(() => { 22 | return composeThemeVariants(theme, presentActiveVariants); 23 | }, [theme, presentActiveVariants]); 24 | 25 | return createElement( 26 | StyledThemeProvider, 27 | { 28 | theme: resolvedTheme, 29 | }, 30 | props.children, 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /stylings/src/ThemedValue.ts: -------------------------------------------------------------------------------- 1 | import { CompileResult, Composer, ThemeOrThemeProps, getIsComposer } from "./Composer"; 2 | import { Primitive, isPrimitive } from "./utils/primitive"; 3 | import { ThemeInput, ThemeOrVariant, getIsThemeOrVariant, getThemeValueByPath } from "./theme"; 4 | 5 | import { HashMap } from "./utils/map/HashMap"; 6 | 7 | interface ThemedComposerHolder { 8 | (propsOrTheme?: unknown): CompileResult; 9 | repeater: ComposerRepeater; 10 | } 11 | 12 | type RepeatStep = 13 | | { 14 | type: "get"; 15 | property: string; 16 | propertyType: "getter" | "method"; 17 | } 18 | | { 19 | type: "apply"; 20 | args: unknown[]; 21 | }; 22 | 23 | function repeatStepsOnComposer(composer: C, steps: RepeatStep[]): C { 24 | let currentResult: unknown = composer; 25 | let currentComposer = currentResult; 26 | 27 | for (const step of steps) { 28 | if (!currentComposer) { 29 | throw new Error("Composer is not defined"); 30 | } 31 | if (step.type === "get") { 32 | currentResult = (currentResult as Composer)[step.property as keyof Composer]; 33 | 34 | if (step.propertyType === "getter") { 35 | currentResult = currentComposer = (currentResult as Composer).rawComposer; 36 | } 37 | } else if (step.type === "apply") { 38 | currentResult = (currentResult as Function).apply(currentComposer, step.args); 39 | currentResult = currentComposer = (currentResult as Composer).rawComposer; 40 | } 41 | } 42 | 43 | return currentResult as C; 44 | } 45 | 46 | interface AnalyzedPrototype { 47 | getters: Set; 48 | methods: Set; 49 | } 50 | 51 | function getPrototypeInfo(prototype: object): AnalyzedPrototype { 52 | const result: AnalyzedPrototype = { 53 | getters: new Set(), 54 | methods: new Set(), 55 | }; 56 | 57 | while (prototype) { 58 | if (!prototype || prototype === Object.prototype) { 59 | break; 60 | } 61 | 62 | const descriptors = Object.getOwnPropertyDescriptors(prototype); 63 | 64 | for (const key in descriptors) { 65 | if (key === "constructor") continue; 66 | 67 | const descriptor = descriptors[key]; 68 | 69 | if (descriptor.get) { 70 | result.getters.add(key); 71 | } else if (typeof descriptor.value === "function") { 72 | result.methods.add(key); 73 | } 74 | } 75 | 76 | prototype = Object.getPrototypeOf(prototype); 77 | } 78 | 79 | return result; 80 | } 81 | 82 | const IS_THEMED_COMPOSER = Symbol("isThemedComposer"); 83 | 84 | const themedComposerHolderProxyHandler: ProxyHandler> = { 85 | get(holder, prop) { 86 | const prototypeInfo = holder.repeater.info.prototypeInfo; 87 | 88 | if (prototypeInfo.methods.has(prop as string)) { 89 | return holder.repeater.addStep({ type: "get", property: prop as string, propertyType: "method" }); 90 | } 91 | 92 | if (prototypeInfo.getters.has(prop as string)) { 93 | return holder.repeater.addStep({ type: "get", property: prop as string, propertyType: "getter" }); 94 | } 95 | 96 | return holder.repeater.info.themeDefaultComposer[prop as keyof Composer]; 97 | }, 98 | apply(target, _thisArg, argArray) { 99 | if (!target.repeater.canCompile) { 100 | return target.repeater.addStep({ type: "apply", args: argArray }); 101 | } 102 | 103 | return target.repeater.compileForProps(argArray[0]); 104 | }, 105 | has(target, prop) { 106 | if (prop === IS_THEMED_COMPOSER) { 107 | return true; 108 | } 109 | 110 | return Reflect.has(target.repeater.info.themeDefaultComposer, prop); 111 | }, 112 | set() { 113 | throw new Error("Cannot set a property on a themed composer"); 114 | }, 115 | }; 116 | 117 | function getThemeFromCallArg(propsOrTheme?: ThemeOrThemeProps): ThemeOrVariant | null { 118 | if (!propsOrTheme) { 119 | return null; 120 | } 121 | 122 | if (getIsThemeOrVariant(propsOrTheme)) { 123 | return propsOrTheme as ThemeOrVariant; 124 | } 125 | 126 | if (!("theme" in propsOrTheme)) return null; 127 | 128 | const maybeTheme = propsOrTheme.theme; 129 | 130 | if (maybeTheme === undefined) { 131 | return null; 132 | } 133 | 134 | if (getIsThemeOrVariant(maybeTheme)) { 135 | return maybeTheme as ThemeOrVariant; 136 | } 137 | 138 | throw new Error("There is some value provided as theme in props, but it is has unknown type"); 139 | } 140 | 141 | export function getIsThemedComposer(value: unknown): value is Composer { 142 | if (isPrimitive(value)) return false; 143 | 144 | return IS_THEMED_COMPOSER in (value as object); 145 | } 146 | 147 | function createRepeaterProxy(repeater: ComposerRepeater) { 148 | const getThemedValue: ThemedComposerHolder = () => null; 149 | getThemedValue.repeater = repeater; 150 | 151 | return new Proxy(getThemedValue, themedComposerHolderProxyHandler) as unknown as C; 152 | } 153 | 154 | function createRepeaterRoot(defaultComposer: C, path: string) { 155 | const repeater = new ComposerRepeater( 156 | { 157 | themeDefaultComposer: defaultComposer, 158 | path, 159 | prototypeInfo: getPrototypeInfo(defaultComposer), 160 | }, 161 | [], 162 | ); 163 | 164 | return createRepeaterProxy(repeater); 165 | } 166 | 167 | interface ThemeComposerInfo { 168 | themeDefaultComposer: C; 169 | path: string; 170 | prototypeInfo: AnalyzedPrototype; 171 | } 172 | 173 | class ComposerRepeater { 174 | private addStepCache = new HashMap(); 175 | private compileForComposerCache = new WeakMap(); 176 | 177 | constructor( 178 | readonly info: ThemeComposerInfo, 179 | readonly steps: RepeatStep[], 180 | ) {} 181 | 182 | get canCompile(): boolean { 183 | const lastStep = this.steps.at(-1); 184 | 185 | if (!lastStep) return true; 186 | 187 | if (lastStep.type === "apply") return true; 188 | 189 | return lastStep.propertyType === "getter" ? true : false; 190 | } 191 | 192 | addStep(step: RepeatStep): C { 193 | let childComposer = this.addStepCache.get(step); 194 | 195 | if (childComposer) { 196 | return childComposer; 197 | } 198 | 199 | childComposer = createRepeaterProxy(new ComposerRepeater(this.info, [...this.steps, step])); 200 | 201 | this.addStepCache.set(step, childComposer); 202 | 203 | return childComposer; 204 | } 205 | 206 | private getComposerFromCallArg(propsOrTheme?: ThemeOrThemeProps): C { 207 | const theme = getThemeFromCallArg(propsOrTheme); 208 | 209 | if (!theme) { 210 | return this.info.themeDefaultComposer; 211 | } 212 | 213 | if (!getIsThemeOrVariant(theme)) { 214 | throw new Error("Theme is not composable"); 215 | } 216 | 217 | const maybeComposer = getThemeValueByPath(theme, this.info.path); 218 | 219 | if (!maybeComposer) { 220 | return this.info.themeDefaultComposer; 221 | } 222 | 223 | if (!getIsComposer(maybeComposer)) { 224 | throw new Error("Theme value is not a composer"); 225 | } 226 | 227 | return maybeComposer as C; 228 | } 229 | 230 | compileForComposer(sourceComposer: C): CompileResult { 231 | let result = this.compileForComposerCache.get(sourceComposer); 232 | 233 | if (result !== undefined) { 234 | return result; 235 | } 236 | 237 | const finalComposer = repeatStepsOnComposer(sourceComposer, this.steps); 238 | 239 | if (!finalComposer) { 240 | throw new Error("Failed to get theme value."); 241 | } 242 | 243 | result = finalComposer.compile(); 244 | 245 | this.compileForComposerCache.set(sourceComposer, result); 246 | 247 | return result; 248 | } 249 | 250 | compileForProps(props: ThemeOrThemeProps): CompileResult { 251 | let composer = this.getComposerFromCallArg(props); 252 | 253 | return this.compileForComposer(composer); 254 | } 255 | } 256 | 257 | export type ThemedValueGetter = (props?: ThemeOrThemeProps) => V; 258 | 259 | function createThemedValueGetter(path: string, defaultValue: T): ThemedValueGetter { 260 | return function getThemedValue(props?: ThemeOrThemeProps) { 261 | const theme = getThemeFromCallArg(props); 262 | 263 | if (!theme) { 264 | return defaultValue; 265 | } 266 | 267 | const themeValue = getThemeValueByPath(theme, path); 268 | 269 | if (themeValue === undefined) { 270 | return defaultValue; 271 | } 272 | 273 | return themeValue as T; 274 | }; 275 | } 276 | 277 | export type ThemedValueInput = Primitive | Composer; 278 | 279 | export type ThemedValue = V extends Primitive ? ThemedValueGetter : V; 280 | 281 | export function createThemedValue(path: string, defaultValue: V): ThemedValue { 282 | if (getIsComposer(defaultValue)) { 283 | return createRepeaterRoot(defaultValue, path) as ThemedValue; 284 | } 285 | 286 | return createThemedValueGetter(path, defaultValue) as ThemedValue; 287 | } 288 | -------------------------------------------------------------------------------- /stylings/src/TransitionComposer.ts: -------------------------------------------------------------------------------- 1 | import { Composer, composer } from "./Composer"; 2 | import { DEFAULT_TRANSITION_DURATION_MS, DEFAULT_TRANSITION_EASING } from "./defaults"; 3 | import { Length, addUnit, multiplyUnit } from "./utils"; 4 | 5 | import type { CSSProperty } from "./types"; 6 | import { ComposerConfig } from "./ComposerConfig"; 7 | import { Property } from "csstype"; 8 | import { getHasValue } from "./utils/maybeValue"; 9 | 10 | interface StyledTransitionConfig { 11 | easing: string; 12 | duration: Length; 13 | properties: CSSProperty[] | "all"; 14 | slowRelease: boolean; 15 | } 16 | 17 | const config = new ComposerConfig({ 18 | easing: DEFAULT_TRANSITION_EASING, 19 | duration: DEFAULT_TRANSITION_DURATION_MS, 20 | properties: ["all"], 21 | slowRelease: false, 22 | }); 23 | 24 | export class TransitionComposer extends Composer { 25 | easing(easing: Property.TransitionTimingFunction) { 26 | return this.updateConfig(config, { easing }); 27 | } 28 | 29 | duration(duration: Length) { 30 | return this.updateConfig(config, { duration }); 31 | } 32 | 33 | get slowRelease() { 34 | return this.updateConfig(config, { slowRelease: true }); 35 | } 36 | 37 | get all() { 38 | return this.property("all"); 39 | } 40 | 41 | get colors() { 42 | return this.property("color", "background-color", "border-color", "text-decoration-color", "fill", "stroke"); 43 | } 44 | 45 | get common() { 46 | return this.property( 47 | "color", 48 | "background-color", 49 | "border-color", 50 | "text-decoration-color", 51 | "fill", 52 | "stroke", 53 | "opacity", 54 | "box-shadow", 55 | "transform", 56 | "filter", 57 | "backdrop-filter", 58 | ); 59 | } 60 | 61 | property(...properties: CSSProperty[]) { 62 | return this.updateConfig(config, { properties }); 63 | } 64 | 65 | compile() { 66 | if (getHasValue(this.compileCache)) return this.compileCache; 67 | 68 | const { easing, duration, properties, slowRelease } = this.getConfig(config); 69 | const propertiesString = properties === "all" ? "all" : properties.join(", "); 70 | 71 | const styles = [ 72 | `transition-property: ${propertiesString};`, 73 | `transition-timing-function: ${easing};`, 74 | `transition-duration: ${multiplyUnit(duration, 3, "ms")};`, 75 | ]; 76 | 77 | if (slowRelease) { 78 | styles.push(` 79 | &:hover { 80 | transition-duration: ${addUnit(duration, "ms")}; 81 | } 82 | `); 83 | } 84 | 85 | return super.compile(styles); 86 | } 87 | } 88 | 89 | export const $transition = composer(TransitionComposer); 90 | -------------------------------------------------------------------------------- /stylings/src/UI.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, createElement, FunctionComponent, HTMLAttributes, ReactNode, type JSX } from "react"; 2 | 3 | import styled from "styled-components"; 4 | import { resolveStylesInput, StylesInput } from "./input"; 5 | import { type AnyStyledComposer } from "./Composer"; 6 | import { useElementDebugUIId } from "./utils/debug"; 7 | import { useInnerForwardRef, useSameArray } from "./utils/hooks"; 8 | import { memoizeFn } from "./utils/memoize"; 9 | import { registerStylesComponent } from "./utils/registry"; 10 | 11 | type IntrinsicElementName = keyof JSX.IntrinsicElements; 12 | 13 | interface StylesExtraProps { 14 | as?: IntrinsicElementName; 15 | styles?: StylesInput; 16 | } 17 | 18 | type SWIntrinsicProps = ComponentProps & StylesExtraProps; 19 | 20 | type SWIntrinsicComponent = (props: SWIntrinsicProps) => ReactNode; 21 | 22 | type InferSWComponentFromKey = T extends `${string}_${infer T}` 23 | ? T extends IntrinsicElementName 24 | ? SWIntrinsicComponent 25 | : never 26 | : never; 27 | 28 | type CustomStylesComponents = { 29 | [P in `${string}_${IntrinsicElementName}`]: InferSWComponentFromKey

; 30 | } & Record>; 31 | 32 | const createStylesComponent = memoizeFn(function createStylesComponent( 33 | intrinsicComponentType: T, 34 | customName?: string, 35 | ): SWIntrinsicComponent { 36 | function StylesComponent({ styles, as: asType = intrinsicComponentType, ref, ...props }: SWIntrinsicProps) { 37 | const innerRef = useInnerForwardRef(ref); 38 | 39 | const stylesList = useSameArray(resolveStylesInput(styles)); 40 | 41 | useElementDebugUIId(innerRef, customName); 42 | 43 | return createElement(SW, { 44 | // Make it always first in the inspector 45 | "data-ui": customName, 46 | as: asType, 47 | ref: innerRef, 48 | $styles: stylesList, 49 | ...(props as HTMLAttributes), 50 | }) as ReactNode; 51 | } 52 | 53 | StylesComponent.displayName = `StylesComponent${intrinsicComponentType}`; 54 | 55 | registerStylesComponent(StylesComponent as FunctionComponent); 56 | 57 | return StylesComponent; 58 | }); 59 | 60 | const createStylingsComponentFromCustomName = memoizeFn(function createStylingsComponentFromCustomName( 61 | customName: string, 62 | ) { 63 | if (!customName.includes("_")) return createStylesComponent("div", customName); 64 | 65 | const [componentName, intrinsicElement] = customName.split("_"); 66 | 67 | if (!intrinsicElement) return createStylesComponent("div", customName); 68 | 69 | if (!getIsIntrinsicElementName(intrinsicElement)) return createStylesComponent("div", customName); 70 | 71 | return createStylesComponent(intrinsicElement, componentName); 72 | }); 73 | 74 | const stylingsBuiltInComponents = { 75 | a: createStylesComponent("a"), 76 | abbr: createStylesComponent("abbr"), 77 | address: createStylesComponent("address"), 78 | area: createStylesComponent("area"), 79 | article: createStylesComponent("article"), 80 | aside: createStylesComponent("aside"), 81 | audio: createStylesComponent("audio"), 82 | b: createStylesComponent("b"), 83 | base: createStylesComponent("base"), 84 | bdi: createStylesComponent("bdi"), 85 | bdo: createStylesComponent("bdo"), 86 | blockquote: createStylesComponent("blockquote"), 87 | body: createStylesComponent("body"), 88 | br: createStylesComponent("br"), 89 | button: createStylesComponent("button"), 90 | canvas: createStylesComponent("canvas"), 91 | caption: createStylesComponent("caption"), 92 | cite: createStylesComponent("cite"), 93 | code: createStylesComponent("code"), 94 | col: createStylesComponent("col"), 95 | colgroup: createStylesComponent("colgroup"), 96 | data: createStylesComponent("data"), 97 | datalist: createStylesComponent("datalist"), 98 | dd: createStylesComponent("dd"), 99 | del: createStylesComponent("del"), 100 | details: createStylesComponent("details"), 101 | dfn: createStylesComponent("dfn"), 102 | dialog: createStylesComponent("dialog"), 103 | div: createStylesComponent("div"), 104 | dl: createStylesComponent("dl"), 105 | dt: createStylesComponent("dt"), 106 | em: createStylesComponent("em"), 107 | embed: createStylesComponent("embed"), 108 | fieldset: createStylesComponent("fieldset"), 109 | figcaption: createStylesComponent("figcaption"), 110 | figure: createStylesComponent("figure"), 111 | footer: createStylesComponent("footer"), 112 | form: createStylesComponent("form"), 113 | h1: createStylesComponent("h1"), 114 | h2: createStylesComponent("h2"), 115 | h3: createStylesComponent("h3"), 116 | h4: createStylesComponent("h4"), 117 | h5: createStylesComponent("h5"), 118 | h6: createStylesComponent("h6"), 119 | head: createStylesComponent("head"), 120 | header: createStylesComponent("header"), 121 | hr: createStylesComponent("hr"), 122 | html: createStylesComponent("html"), 123 | i: createStylesComponent("i"), 124 | iframe: createStylesComponent("iframe"), 125 | img: createStylesComponent("img"), 126 | input: createStylesComponent("input"), 127 | ins: createStylesComponent("ins"), 128 | kbd: createStylesComponent("kbd"), 129 | label: createStylesComponent("label"), 130 | legend: createStylesComponent("legend"), 131 | li: createStylesComponent("li"), 132 | link: createStylesComponent("link"), 133 | main: createStylesComponent("main"), 134 | map: createStylesComponent("map"), 135 | mark: createStylesComponent("mark"), 136 | menu: createStylesComponent("menu"), 137 | meta: createStylesComponent("meta"), 138 | meter: createStylesComponent("meter"), 139 | nav: createStylesComponent("nav"), 140 | noscript: createStylesComponent("noscript"), 141 | object: createStylesComponent("object"), 142 | ol: createStylesComponent("ol"), 143 | optgroup: createStylesComponent("optgroup"), 144 | option: createStylesComponent("option"), 145 | output: createStylesComponent("output"), 146 | p: createStylesComponent("p"), 147 | picture: createStylesComponent("picture"), 148 | pre: createStylesComponent("pre"), 149 | progress: createStylesComponent("progress"), 150 | q: createStylesComponent("q"), 151 | rp: createStylesComponent("rp"), 152 | rt: createStylesComponent("rt"), 153 | ruby: createStylesComponent("ruby"), 154 | s: createStylesComponent("s"), 155 | samp: createStylesComponent("samp"), 156 | script: createStylesComponent("script"), 157 | section: createStylesComponent("section"), 158 | select: createStylesComponent("select"), 159 | small: createStylesComponent("small"), 160 | source: createStylesComponent("source"), 161 | span: createStylesComponent("span"), 162 | strong: createStylesComponent("strong"), 163 | style: createStylesComponent("style"), 164 | sub: createStylesComponent("sub"), 165 | summary: createStylesComponent("summary"), 166 | sup: createStylesComponent("sup"), 167 | table: createStylesComponent("table"), 168 | tbody: createStylesComponent("tbody"), 169 | td: createStylesComponent("td"), 170 | template: createStylesComponent("template"), 171 | textarea: createStylesComponent("textarea"), 172 | tfoot: createStylesComponent("tfoot"), 173 | th: createStylesComponent("th"), 174 | thead: createStylesComponent("thead"), 175 | time: createStylesComponent("time"), 176 | title: createStylesComponent("title"), 177 | tr: createStylesComponent("tr"), 178 | track: createStylesComponent("track"), 179 | u: createStylesComponent("u"), 180 | ul: createStylesComponent("ul"), 181 | var: createStylesComponent("var"), 182 | video: createStylesComponent("video"), 183 | wbr: createStylesComponent("wbr"), 184 | // SVG elements 185 | circle: createStylesComponent("circle"), 186 | clipPath: createStylesComponent("clipPath"), 187 | defs: createStylesComponent("defs"), 188 | desc: createStylesComponent("desc"), 189 | ellipse: createStylesComponent("ellipse"), 190 | feBlend: createStylesComponent("feBlend"), 191 | feColorMatrix: createStylesComponent("feColorMatrix"), 192 | feComponentTransfer: createStylesComponent("feComponentTransfer"), 193 | feComposite: createStylesComponent("feComposite"), 194 | feConvolveMatrix: createStylesComponent("feConvolveMatrix"), 195 | feDiffuseLighting: createStylesComponent("feDiffuseLighting"), 196 | feDisplacementMap: createStylesComponent("feDisplacementMap"), 197 | feDistantLight: createStylesComponent("feDistantLight"), 198 | feDropShadow: createStylesComponent("feDropShadow"), 199 | feFlood: createStylesComponent("feFlood"), 200 | feFuncA: createStylesComponent("feFuncA"), 201 | feFuncB: createStylesComponent("feFuncB"), 202 | feFuncG: createStylesComponent("feFuncG"), 203 | feFuncR: createStylesComponent("feFuncR"), 204 | feGaussianBlur: createStylesComponent("feGaussianBlur"), 205 | feImage: createStylesComponent("feImage"), 206 | feMerge: createStylesComponent("feMerge"), 207 | feMergeNode: createStylesComponent("feMergeNode"), 208 | feMorphology: createStylesComponent("feMorphology"), 209 | feOffset: createStylesComponent("feOffset"), 210 | fePointLight: createStylesComponent("fePointLight"), 211 | feSpecularLighting: createStylesComponent("feSpecularLighting"), 212 | feSpotLight: createStylesComponent("feSpotLight"), 213 | feTile: createStylesComponent("feTile"), 214 | feTurbulence: createStylesComponent("feTurbulence"), 215 | filter: createStylesComponent("filter"), 216 | foreignObject: createStylesComponent("foreignObject"), 217 | g: createStylesComponent("g"), 218 | image: createStylesComponent("image"), 219 | line: createStylesComponent("line"), 220 | linearGradient: createStylesComponent("linearGradient"), 221 | marker: createStylesComponent("marker"), 222 | mask: createStylesComponent("mask"), 223 | metadata: createStylesComponent("metadata"), 224 | mpath: createStylesComponent("mpath"), 225 | path: createStylesComponent("path"), 226 | pattern: createStylesComponent("pattern"), 227 | polygon: createStylesComponent("polygon"), 228 | polyline: createStylesComponent("polyline"), 229 | radialGradient: createStylesComponent("radialGradient"), 230 | rect: createStylesComponent("rect"), 231 | set: createStylesComponent("set"), 232 | stop: createStylesComponent("stop"), 233 | switch: createStylesComponent("switch"), 234 | symbol: createStylesComponent("symbol"), 235 | text: createStylesComponent("text"), 236 | textPath: createStylesComponent("textPath"), 237 | tspan: createStylesComponent("tspan"), 238 | use: createStylesComponent("use"), 239 | view: createStylesComponent("view"), 240 | animate: createStylesComponent("animate"), 241 | animateMotion: createStylesComponent("animateMotion"), 242 | animateTransform: createStylesComponent("animateTransform"), 243 | big: createStylesComponent("big"), 244 | center: createStylesComponent("center"), 245 | hgroup: createStylesComponent("hgroup"), 246 | keygen: createStylesComponent("keygen"), 247 | menuitem: createStylesComponent("menuitem"), 248 | noindex: createStylesComponent("noindex"), 249 | param: createStylesComponent("param"), 250 | search: createStylesComponent("search"), 251 | slot: createStylesComponent("slot"), 252 | svg: createStylesComponent("svg"), 253 | webview: createStylesComponent("webview"), 254 | } satisfies Record>; 255 | 256 | function getIsIntrinsicElementName(element: string | symbol): element is IntrinsicElementName { 257 | return element in stylingsBuiltInComponents; 258 | } 259 | 260 | export type StylesComponentsLibrary = typeof stylingsBuiltInComponents & CustomStylesComponents; 261 | 262 | export const UI = new Proxy(stylingsBuiltInComponents, { 263 | get(builtInElements, prop) { 264 | if (getIsIntrinsicElementName(prop)) return builtInElements[prop]; 265 | 266 | return createStylingsComponentFromCustomName(prop as string); 267 | }, 268 | }) as StylesComponentsLibrary; 269 | 270 | const SW = styled.div<{ $styles?: AnyStyledComposer[] }>` 271 | ${(props) => props.$styles} 272 | `; 273 | -------------------------------------------------------------------------------- /stylings/src/compilation.ts: -------------------------------------------------------------------------------- 1 | import { ComposerStyle, getIsComposer } from "./Composer"; 2 | import { Interpolation, RuleSet, css } from "styled-components"; 3 | 4 | import { isNotNullish } from "./utils/nullish"; 5 | import { memoizeFn } from "./utils/memoize"; 6 | import { mutateArray } from "./utils/array"; 7 | 8 | export function simplifyRule(ruleSet: RuleSet) { 9 | mutateArray(ruleSet, (item, _index, controller) => { 10 | if (typeof item === "string") { 11 | const trimmed = item.trim().replace(/\s+/g, " "); 12 | 13 | if (trimmed.length === 0) return controller.remove; 14 | 15 | if (trimmed.length === item.length) return controller.noChange; 16 | 17 | return trimmed; 18 | } 19 | 20 | return controller.noChange; 21 | }); 22 | 23 | return ruleSet; 24 | } 25 | 26 | export const compileComposerStyles = memoizeFn( 27 | (styles: ComposerStyle[]): RuleSet => { 28 | const precompiledStyles = styles 29 | .map((style) => { 30 | if (getIsComposer(style)) return style.compile(); 31 | 32 | return style; 33 | }) 34 | .filter(isNotNullish); 35 | 36 | // return precompiledStyles; 37 | 38 | // prettier-ignore 39 | const result = css`${precompiledStyles as Interpolation}`; 40 | 41 | // simplifyRule(result); 42 | 43 | return result; 44 | }, 45 | { mode: "weak" }, 46 | ); 47 | -------------------------------------------------------------------------------- /stylings/src/defaults.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_TRANSITION_EASING = `cubic-bezier(0.4, 0, 0.2, 1)`; 2 | export const DEFAULT_TRANSITION_DURATION_MS = 150; 3 | -------------------------------------------------------------------------------- /stylings/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Composer, type AnyStyledComposer, type StyledComposer } from "./Composer"; 2 | export { $animation, AnimationComposer } from "./AnimationComposer"; 3 | export { $color, ColorComposer } from "./ColorComposer"; 4 | export { $common, CommonComposer } from "./CommonComposer"; 5 | export { $flex, FlexComposer } from "./FlexComposer"; 6 | export { $font, FontComposer } from "./FontComposer"; 7 | export { $grid, GridComposer } from "./GridComposer"; 8 | export { $shadow, ShadowComposer } from "./ShadowComposer"; 9 | export { $size, SizeComposer } from "./SizeComposer"; 10 | export { $frame, FrameComposer } from "./SurfaceComposer"; 11 | export { $transition, TransitionComposer } from "./TransitionComposer"; 12 | export { resolveStylesInput, type PropsWithStyles } from "./input"; 13 | export { ComposerConfig, composerConfig } from "./ComposerConfig"; 14 | export * from "./types"; 15 | export { UI } from "./UI"; 16 | export * from "./theme"; 17 | export * from "./ThemedValue"; 18 | export * from "./themeHooks"; 19 | -------------------------------------------------------------------------------- /stylings/src/input.ts: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef, ElementType, HTMLAttributes } from "react"; 2 | 3 | import { AnyStyledComposer } from "./Composer"; 4 | import { Falsy } from "./utils/nullish"; 5 | 6 | export type StylesInput = Falsy | AnyStyledComposer | Array; 7 | 8 | export type StyledStylesProps = { 9 | $styles?: StylesInput; 10 | }; 11 | 12 | export type PropsWithStyles = T & { 13 | styles?: StylesInput; 14 | }; 15 | 16 | export function resolveStylesInput(styles?: StylesInput): Array { 17 | if (!styles) return []; 18 | 19 | if (!Array.isArray(styles)) return [styles]; 20 | 21 | return styles.map(resolveStylesInput).flat(); 22 | } 23 | 24 | export type HtmlAttributesWithStyles = PropsWithStyles>; 25 | export type ComponentPropsWithStylesWithoutRef = PropsWithStyles>; 26 | 27 | export function injectStyles(props: { $styles?: StylesInput }) { 28 | return resolveStylesInput(props.$styles); 29 | } 30 | -------------------------------------------------------------------------------- /stylings/src/theme.ts: -------------------------------------------------------------------------------- 1 | import { Composer, PickComposer, getIsComposer } from "./Composer"; 2 | import { Primitive, isPrimitive } from "./utils/primitive"; 3 | import { ThemedValue, ThemedValueInput, createThemedValue } from "./ThemedValue"; 4 | import { createNestedRecordPropertiesMap, mapNestedRecord } from "./utils/nestedRecord"; 5 | 6 | export type ThemeInputValue = Primitive | Composer | ThemeInput; 7 | 8 | export type ThemeInput = { 9 | [key: string]: ThemeInputValue; 10 | }; 11 | 12 | export type PropertiesMap = Map; 13 | 14 | const PROPERTIES = Symbol("theme-composers"); 15 | const VARIANT_CHANGED_PROPERTIES = Symbol("variant-changed-properties"); 16 | const DEFAULT_THEME = Symbol("default-theme"); 17 | 18 | interface ThemeData { 19 | [PROPERTIES]: PropertiesMap; 20 | [DEFAULT_THEME]: Theme; 21 | } 22 | 23 | interface ThemeVariantData extends ThemeData { 24 | [VARIANT_CHANGED_PROPERTIES]: PropertiesMap; 25 | } 26 | 27 | export type Theme = { 28 | [K in keyof T]: T[K] extends ThemedValueInput ? ThemedValue : T[K] extends ThemeInput ? Theme : never; 29 | } & ThemeData; 30 | 31 | export type ThemeVariantInputValue = T extends Primitive 32 | ? T 33 | : T extends Composer 34 | ? PickComposer 35 | : T extends ThemeInput 36 | ? ThemeVariantInput 37 | : never; 38 | 39 | export type ThemeVariantInput = { 40 | [K in keyof T]?: ThemeVariantInputValue; 41 | }; 42 | 43 | export type ThemeOrVariant = Theme | ThemeVariant; 44 | 45 | export function createTheme(themeInput: T): Theme { 46 | const propertiesMap = createNestedRecordPropertiesMap(themeInput) as PropertiesMap; 47 | 48 | const theme = mapNestedRecord(themeInput, (value, path) => { 49 | if (isPrimitive(value)) { 50 | return createThemedValue(path, value); 51 | } 52 | 53 | if (getIsComposer(value)) { 54 | // value.composer - ensure we dont use proxied version 55 | return createThemedValue(path, value.rawComposer); 56 | } 57 | 58 | return value; 59 | }) as Theme; 60 | 61 | ensureRawComposersInPropertiesMap(propertiesMap); 62 | 63 | theme[PROPERTIES] = propertiesMap; 64 | theme[DEFAULT_THEME] = theme as Theme; 65 | 66 | return theme; 67 | } 68 | 69 | export interface ThemeVariant extends ThemeVariantData {} 70 | 71 | function ensureRawComposersInPropertiesMap(propertiesMap: PropertiesMap) { 72 | for (const [path, value] of propertiesMap.entries()) { 73 | if (getIsComposer(value)) { 74 | propertiesMap.set(path, value.rawComposer); 75 | } 76 | } 77 | } 78 | 79 | export function createThemeVariant( 80 | sourceTheme: Theme, 81 | variantInput: ThemeVariantInput, 82 | ): ThemeVariant { 83 | if (!getIsTheme(sourceTheme)) { 84 | throw new Error("Can only create theme variant from source theme"); 85 | } 86 | 87 | const changedPropertiesMap = createNestedRecordPropertiesMap(variantInput) as PropertiesMap; 88 | 89 | const propertiesClone: PropertiesMap = new Map(); 90 | 91 | for (const [path, value] of changedPropertiesMap.entries()) { 92 | propertiesClone.set(path, value); 93 | } 94 | 95 | ensureRawComposersInPropertiesMap(propertiesClone); 96 | ensureRawComposersInPropertiesMap(changedPropertiesMap); 97 | 98 | const result: ThemeVariant = { 99 | [PROPERTIES]: propertiesClone, 100 | [VARIANT_CHANGED_PROPERTIES]: changedPropertiesMap, 101 | [DEFAULT_THEME]: sourceTheme as Theme, 102 | }; 103 | 104 | return result; 105 | } 106 | 107 | export function getIsThemeOrVariant(value: unknown): value is ThemeOrVariant { 108 | if (typeof value !== "object" || value === null) return false; 109 | 110 | return PROPERTIES in value; 111 | } 112 | 113 | export function getIsTheme(value: unknown): value is Theme { 114 | if (typeof value !== "object" || value === null) return false; 115 | 116 | return DEFAULT_THEME in value && value[DEFAULT_THEME] === value; 117 | } 118 | 119 | export function getIsThemeVariant(value: unknown): value is ThemeVariant { 120 | if (typeof value !== "object" || value === null) return false; 121 | 122 | return DEFAULT_THEME in value && value[DEFAULT_THEME] !== value; 123 | } 124 | 125 | /** 126 | * @internal 127 | */ 128 | export function getThemeValueByPath( 129 | theme: ThemeOrVariant, 130 | path: string, 131 | ): ThemedValue | undefined { 132 | return theme[PROPERTIES].get(path); 133 | } 134 | 135 | export function composeThemeVariants( 136 | sourceTheme: Theme, 137 | variants: ThemeVariant[], 138 | ): ThemeVariant { 139 | const changedProperties: PropertiesMap = new Map(); 140 | 141 | for (const variant of variants) { 142 | if (variant[DEFAULT_THEME] !== sourceTheme) { 143 | throw new Error("All variants must have the same source theme"); 144 | } 145 | 146 | const variantProperties = variant[VARIANT_CHANGED_PROPERTIES]; 147 | 148 | for (const [path, value] of variantProperties.entries()) { 149 | changedProperties.set(path, value); 150 | } 151 | } 152 | 153 | const resolvedProperties = new Map(sourceTheme[PROPERTIES]); 154 | 155 | for (const [path, value] of changedProperties.entries()) { 156 | resolvedProperties.set(path, value); 157 | } 158 | 159 | ensureRawComposersInPropertiesMap(resolvedProperties); 160 | ensureRawComposersInPropertiesMap(changedProperties); 161 | 162 | return { 163 | [PROPERTIES]: resolvedProperties, 164 | [VARIANT_CHANGED_PROPERTIES]: changedProperties, 165 | [DEFAULT_THEME]: sourceTheme as Theme, 166 | }; 167 | } 168 | -------------------------------------------------------------------------------- /stylings/src/themeHooks.ts: -------------------------------------------------------------------------------- 1 | import { ThemedValueGetter } from "./ThemedValue"; 2 | import { getIsThemeOrVariant } from "./theme"; 3 | import { useTheme } from "styled-components"; 4 | 5 | export function useThemeValue(themedValue: ThemedValueGetter): T { 6 | const theme = useTheme(); 7 | 8 | if (!getIsThemeOrVariant(theme)) { 9 | throw new Error("useThemeValue must be used within a ThemeProvider"); 10 | } 11 | 12 | return themedValue(theme); 13 | } 14 | -------------------------------------------------------------------------------- /stylings/src/types.ts: -------------------------------------------------------------------------------- 1 | import { StandardPropertiesHyphen, SvgPropertiesHyphen } from "csstype"; 2 | 3 | export interface CSSStyle extends StandardPropertiesHyphen, SvgPropertiesHyphen {} 4 | 5 | export type CSSProperty = keyof CSSStyle; 6 | export type CSSValue = CSSStyle[T]; 7 | 8 | export type FilterObjectByValue = { 9 | [K in keyof T as T[K] extends V ? K : never]: T[K]; 10 | }; 11 | 12 | export type FilterKeysByValue = keyof FilterObjectByValue; 13 | -------------------------------------------------------------------------------- /stylings/src/utils.ts: -------------------------------------------------------------------------------- 1 | interface ParsedUnit { 2 | value: number; 3 | unit?: string; 4 | } 5 | 6 | function parseUnit(value: Length): ParsedUnit { 7 | if (typeof value === "number") { 8 | return { value }; 9 | } 10 | 11 | const numPartRegex = /^\d+(\.\d+)?/; 12 | 13 | const numPart = value.match(numPartRegex); 14 | 15 | if (!numPart) { 16 | throw new Error(`Invalid unit value: ${value}`); 17 | } 18 | 19 | const unitPart = value.slice(numPart[0].length); 20 | 21 | return { value: parseFloat(numPart[0]), unit: unitPart }; 22 | } 23 | 24 | export function addUnit(value: number | string, unit: string = "rem") { 25 | if (typeof value === "number") { 26 | return `${value}${unit}`; 27 | } 28 | 29 | return value; 30 | } 31 | 32 | export function multiplyUnit(value: Length, multiplier: number, defaultUnit: string = "ms") { 33 | const { value: num, unit } = parseUnit(value); 34 | return `${num * multiplier}${unit || defaultUnit}`; 35 | } 36 | 37 | export type Length = string | number; 38 | 39 | export function isInteger(value: number) { 40 | return Number.isInteger(value); 41 | } 42 | 43 | export function isDefined(value: T | undefined): value is T { 44 | return value !== undefined; 45 | } 46 | -------------------------------------------------------------------------------- /stylings/src/utils/array.ts: -------------------------------------------------------------------------------- 1 | const NO_CHANGE = Symbol("noChange"); 2 | const REMOVE = Symbol("remove"); 3 | 4 | type MutationResult = T | typeof NO_CHANGE | typeof REMOVE; 5 | 6 | const mutationController = { 7 | noChange: NO_CHANGE, 8 | remove: REMOVE, 9 | } as const; 10 | 11 | type MutationController = typeof mutationController; 12 | 13 | export function mutateArray( 14 | array: T[], 15 | getMutated: (item: T, index: number, controller: MutationController) => MutationResult, 16 | ) { 17 | for (let i = 0; i < array.length; i++) { 18 | const result = getMutated(array[i], i, mutationController); 19 | 20 | if (result === NO_CHANGE) continue; 21 | 22 | if (result === REMOVE) { 23 | array.splice(i, 1); 24 | i--; 25 | continue; 26 | } 27 | 28 | array[i] = result; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /stylings/src/utils/assert.ts: -------------------------------------------------------------------------------- 1 | import { Falsy } from "./nullish"; 2 | 3 | type ErrorInput = string | Error; 4 | 5 | function createError(input: ErrorInput) { 6 | if (typeof input === "string") { 7 | return new Error(input); 8 | } 9 | 10 | return input; 11 | } 12 | 13 | export function assertGet(input: Falsy | T, error: ErrorInput): T { 14 | if (!input) { 15 | throw createError(error); 16 | } 17 | 18 | return input; 19 | } 20 | -------------------------------------------------------------------------------- /stylings/src/utils/color.ts: -------------------------------------------------------------------------------- 1 | import Color from "color"; 2 | import { memoizeFn } from "./memoize"; 3 | 4 | const HOVER_COLOR_CHANGE = 15; 5 | 6 | function getColorLightnessVariant(color: string, ratio = 1): string { 7 | if (isColorDark(color)) { 8 | return changeColorLightness(color, ratio); 9 | } 10 | 11 | return changeColorLightness(color, -ratio); 12 | } 13 | 14 | export function getHighlightedColor(color: string, ratio = 1): string { 15 | return getColorLightnessVariant(color, HOVER_COLOR_CHANGE * ratio); 16 | } 17 | 18 | export function getColorForeground(color: string) { 19 | if (isColorDark(color)) { 20 | return "hsl(0, 0%, 100%)"; 21 | } 22 | 23 | return "hsl(0, 0%, 0%)"; 24 | } 25 | 26 | export function getColorBorderColor(color: string) { 27 | return getHighlightedColor(color, 0.75); 28 | } 29 | 30 | export const setColorOpacity = memoizeFn(function setColorOpacity(color: string, opacity: number): string { 31 | const colorInstance = new Color(color); 32 | return colorInstance 33 | .hsl() 34 | .fade(1 - opacity) 35 | .hsl() 36 | .toString(); 37 | }); 38 | 39 | export const isColorDark = memoizeFn(function isColorDark(color: string): boolean { 40 | const colorInstance = new Color(color); 41 | return colorInstance.isDark(); 42 | }); 43 | 44 | export const changeColorLightness = memoizeFn(function changeColorLightness(color: string, offset: number): string { 45 | const colorInstance = new Color(color); 46 | const currentLightness = colorInstance.lightness(); 47 | 48 | return colorInstance 49 | .lightness(currentLightness + offset) 50 | .hsl() 51 | .toString(); 52 | }); 53 | 54 | export const blendColors = memoizeFn(function blendColors(color: string, foreground: string, ratio: number): string { 55 | const colorInstance = new Color(color); 56 | const foregroundInstance = new Color(foreground); 57 | 58 | return colorInstance.mix(foregroundInstance, ratio).rgb().toString(); 59 | }); 60 | -------------------------------------------------------------------------------- /stylings/src/utils/convertUnits.ts: -------------------------------------------------------------------------------- 1 | type BaseUnit = "base" | "level"; 2 | 3 | type ConvertableUnit = "rem" | BaseUnit; 4 | 5 | function levelToBase(level: number) { 6 | return Math.pow(2, level); 7 | } 8 | 9 | function baseToLevel(base: number) { 10 | return Math.log2(base); 11 | } 12 | 13 | function baseToRem(base: number) { 14 | return base * 0.25; 15 | } 16 | 17 | function remToBase(rem: number) { 18 | return rem / 0.25; 19 | } 20 | 21 | export function convertUnits(value: number, from: ConvertableUnit, to: ConvertableUnit): number { 22 | if (from === to) { 23 | return value; 24 | } 25 | 26 | let base: number; 27 | 28 | if (from === "base") { 29 | base = value; 30 | } else if (from === "level") { 31 | base = levelToBase(value); 32 | } else if (from === "rem") { 33 | base = remToBase(value); 34 | } else { 35 | throw new Error(`Unknown unit: ${from}`); 36 | } 37 | 38 | let result: number; 39 | 40 | if (to === "base") { 41 | result = base; 42 | } else if (to === "level") { 43 | result = baseToLevel(base); 44 | } else if (to === "rem") { 45 | result = baseToRem(base); 46 | } else { 47 | throw new Error(`Unknown unit: ${to}`); 48 | } 49 | 50 | return result; 51 | } 52 | 53 | export function convertToRem(value: number, from: BaseUnit) { 54 | return convertUnits(value, from, "rem"); 55 | } 56 | -------------------------------------------------------------------------------- /stylings/src/utils/debug.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from "react"; 2 | 3 | import { IS_DEV } from "./env"; 4 | import { getFunctionalComponentsStack } from "./react/internals"; 5 | import { getIsStylesComponent } from "./registry"; 6 | import { useConst } from "./hooks"; 7 | 8 | function getFirstFunctionalParent() { 9 | const stack = getFunctionalComponentsStack(); 10 | 11 | if (!stack) return null; 12 | 13 | for (const ComponentFunction of stack) { 14 | if (getIsStylesComponent(ComponentFunction)) continue; 15 | 16 | return ComponentFunction; 17 | } 18 | 19 | return null; 20 | } 21 | 22 | function getParentComponentName() { 23 | if (typeof window === "undefined") return null; 24 | 25 | if (!IS_DEV) return null; 26 | 27 | const firstFunctionalParent = getFirstFunctionalParent(); 28 | 29 | if (!firstFunctionalParent) return null; 30 | 31 | const functionName = firstFunctionalParent.displayName || firstFunctionalParent.name; 32 | 33 | if (!functionName) return null; 34 | 35 | return functionName; 36 | } 37 | 38 | function useElementDebugUIIdDev(ref: RefObject, customName?: string) { 39 | const parentComponentName = useConst(() => { 40 | return getParentComponentName(); 41 | }); 42 | 43 | useEffect(() => { 44 | const element = ref.current; 45 | 46 | if (!element) return; 47 | 48 | const items = [parentComponentName, customName].filter(Boolean); 49 | 50 | if (!items.length) return; 51 | 52 | element.setAttribute("data-ui", items.join(".")); 53 | }, [ref, customName]); 54 | } 55 | 56 | export const useElementDebugUIId = IS_DEV ? useElementDebugUIIdDev : () => {}; 57 | -------------------------------------------------------------------------------- /stylings/src/utils/env.ts: -------------------------------------------------------------------------------- 1 | export const IS_DEV = process.env.NODE_ENV === "development"; 2 | -------------------------------------------------------------------------------- /stylings/src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import { ForwardedRef, RefObject, useMemo, useRef, useState } from "react"; 2 | 3 | export function useConst(getter: () => T) { 4 | const [value] = useState(getter); 5 | 6 | return value; 7 | } 8 | 9 | export function applyValueToForwardedRef(forwardedRef: ForwardedRef, value: T) { 10 | if (typeof forwardedRef === "function") { 11 | forwardedRef(value); 12 | } else if (forwardedRef != null) { 13 | forwardedRef.current = value; 14 | } 15 | } 16 | 17 | export function useInnerForwardRef(forwardedRef?: ForwardedRef) { 18 | const innerRefObject = useMemo>(() => { 19 | let currentValue: T | null = null; 20 | return { 21 | get current() { 22 | return currentValue; 23 | }, 24 | set current(value) { 25 | currentValue = value; 26 | 27 | if (forwardedRef === undefined) return; 28 | 29 | applyValueToForwardedRef(forwardedRef, value); 30 | }, 31 | }; 32 | }, [forwardedRef]); 33 | 34 | return innerRefObject; 35 | } 36 | 37 | function getAreArraysSame(array1: T[], array2: T[]) { 38 | if (array1.length !== array2.length) return false; 39 | 40 | for (let i = 0; i < array1.length; i++) { 41 | if (array1[i] !== array2[i]) return false; 42 | } 43 | 44 | return true; 45 | } 46 | 47 | export function useSameArray(array: T[]) { 48 | const currentArray = useRef(array); 49 | 50 | if (!getAreArraysSame(currentArray.current, array)) { 51 | currentArray.current = array; 52 | } 53 | 54 | return currentArray.current; 55 | } 56 | -------------------------------------------------------------------------------- /stylings/src/utils/id.ts: -------------------------------------------------------------------------------- 1 | const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 2 | 3 | export function generateId(length = 10) { 4 | return Array.from({ length }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join(""); 5 | } 6 | -------------------------------------------------------------------------------- /stylings/src/utils/json.ts: -------------------------------------------------------------------------------- 1 | export type JSONPrimitive = string | number | boolean | null; 2 | export type JSONArray = JSONValue[]; 3 | export type JSONValue = JSONPrimitive | JSONObject | JSONArray; 4 | export interface JSONObject { 5 | [key: string]: JSONValue; 6 | } 7 | -------------------------------------------------------------------------------- /stylings/src/utils/map/DeepMap.tsx: -------------------------------------------------------------------------------- 1 | const targetSymbol = { symbol: Symbol("DEEP_MAP_TARGET") }; 2 | 3 | export interface MapLike { 4 | get(key: K): V | undefined; 5 | set(key: K, value: V): void; 6 | has(key: K): boolean; 7 | delete(key: K): boolean; 8 | clear?(): void; 9 | } 10 | 11 | export type MapLikeContructor = new () => MapLike; 12 | 13 | type DeepMapLeaf = MapLike | V>; 14 | 15 | export class DeepMap { 16 | readonly root: DeepMapLeaf; 17 | 18 | constructor(private MapToUse: MapLikeContructor = Map) { 19 | this.root = new MapToUse(); 20 | } 21 | 22 | private getFinalTargetMapIfExists(path: unknown[]) { 23 | let currentTarget = this.root; 24 | 25 | for (let part of path) { 26 | if (currentTarget.has(part)) { 27 | currentTarget = currentTarget.get(part)! as DeepMapLeaf; 28 | continue; 29 | } 30 | 31 | return null; 32 | } 33 | 34 | return currentTarget; 35 | } 36 | 37 | private getFinalTargetMap(path: unknown[]) { 38 | let currentTarget = this.root; 39 | 40 | const { MapToUse } = this; 41 | 42 | for (let part of path) { 43 | const targetLeaf = currentTarget.get(part) as DeepMapLeaf | undefined; 44 | 45 | if (targetLeaf !== undefined) { 46 | currentTarget = targetLeaf; 47 | continue; 48 | } 49 | 50 | const nestedMap: DeepMapLeaf = new MapToUse(); 51 | 52 | currentTarget.set(part, nestedMap); 53 | currentTarget = nestedMap; 54 | } 55 | 56 | return currentTarget; 57 | } 58 | 59 | getForArgs(...path: unknown[]) { 60 | const targetMap = this.getFinalTargetMapIfExists(path); 61 | 62 | if (targetMap === null) return undefined; 63 | 64 | return targetMap.get(targetSymbol) as V | undefined; 65 | } 66 | 67 | get(path: unknown[]) { 68 | return this.getForArgs(...path); 69 | } 70 | 71 | getOrCreate

(path: P, create: (path: P) => V) { 72 | const targetMap = this.getFinalTargetMap(path); 73 | 74 | const maybeResult = targetMap.get(targetSymbol) as V | undefined; 75 | 76 | if (maybeResult !== undefined) return maybeResult; 77 | 78 | if (targetMap.has(targetSymbol)) return undefined; 79 | 80 | const result = create(path); 81 | 82 | targetMap.set(targetSymbol, result); 83 | 84 | return result; 85 | } 86 | 87 | getOrCreateCallback

(create: (...path: P) => V, ...path: P) { 88 | const targetMap = this.getFinalTargetMap(path); 89 | 90 | if (targetMap.has(targetSymbol)) { 91 | return targetMap.get(targetSymbol) as V; 92 | } 93 | 94 | const newResult = create(...path); 95 | 96 | targetMap.set(targetSymbol, newResult); 97 | 98 | return newResult; 99 | } 100 | 101 | boundGetOrCreateCallback

(boundTo: any, create: (...path: P) => V, ...path: P) { 102 | const targetMap = this.getFinalTargetMap(path); 103 | 104 | if (targetMap.has(targetSymbol)) { 105 | return targetMap.get(targetSymbol) as V; 106 | } 107 | 108 | const newResult = Reflect.apply(create, boundTo, path); 109 | 110 | targetMap.set(targetSymbol, newResult); 111 | 112 | return newResult; 113 | } 114 | 115 | set(path: unknown[], value: V) { 116 | const targetMap = this.getFinalTargetMap(path); 117 | 118 | targetMap.set(targetSymbol, value); 119 | } 120 | 121 | getAndHas(path: unknown[]): [V, true] | [undefined, false] { 122 | const targetMap = this.getFinalTargetMapIfExists(path); 123 | 124 | if (!targetMap) { 125 | return [undefined, false]; 126 | } 127 | 128 | if (!targetMap.has(targetSymbol)) { 129 | return [undefined, false]; 130 | } 131 | 132 | return [targetMap.get(targetSymbol) as V, true]; 133 | } 134 | 135 | has(path: unknown[]) { 136 | const targetMap = this.getFinalTargetMapIfExists(path); 137 | 138 | if (targetMap === null) return false; 139 | 140 | return targetMap.has(targetSymbol); 141 | } 142 | 143 | delete(path: unknown[]) { 144 | const targetMap = this.getFinalTargetMapIfExists(path); 145 | 146 | if (targetMap === null) return false; 147 | 148 | return targetMap.delete(targetSymbol); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /stylings/src/utils/map/HashMap.tsx: -------------------------------------------------------------------------------- 1 | import { getObjectHash } from "../objectHash"; 2 | 3 | const NO_LAST_KEY = Symbol("NO_LAST_KEY"); 4 | 5 | const MAX_CACHE_SIZE = 10_000; 6 | 7 | export class HashMap { 8 | private map: Map = new Map(); 9 | 10 | constructor(entries?: Iterable) { 11 | if (!entries) return; 12 | 13 | for (const [key, value] of entries) { 14 | this.map.set(getObjectHash(key), value); 15 | } 16 | } 17 | 18 | private lastKey: K | typeof NO_LAST_KEY = NO_LAST_KEY; 19 | private lastKeyHash: number | null = null; 20 | 21 | private removeFirstN(n: number) { 22 | for (const key of this.map.keys()) { 23 | this.map.delete(key); 24 | n--; 25 | if (n <= 0) break; 26 | } 27 | } 28 | 29 | private getKey(key: K) { 30 | /** 31 | * It is likely that we are getting the same key at least twice in a row (eg .has, > get, > set) 32 | * This will prevent us from recalculating the hash twice 33 | * 34 | * Assumption: keys are immutable 35 | */ 36 | if (this.lastKey === key) return this.lastKeyHash!; 37 | 38 | const hash = getObjectHash(key); 39 | 40 | this.lastKey = key; 41 | this.lastKeyHash = hash; 42 | 43 | return hash; 44 | } 45 | 46 | has(key: K) { 47 | return this.map.has(this.getKey(key)); 48 | } 49 | 50 | get(key: K) { 51 | return this.map.get(this.getKey(key)); 52 | } 53 | 54 | set(key: K, value: V) { 55 | if (this.map.size >= MAX_CACHE_SIZE) { 56 | this.removeFirstN(1); 57 | } 58 | 59 | return this.map.set(this.getKey(key), value); 60 | } 61 | 62 | delete(key: K) { 63 | if (this.lastKey === key) { 64 | this.lastKey = NO_LAST_KEY; 65 | this.lastKeyHash = null; 66 | } 67 | 68 | return this.map.delete(this.getKey(key)); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /stylings/src/utils/map/MaybeWeakMap.ts: -------------------------------------------------------------------------------- 1 | import { isPrimitive } from "../primitive"; 2 | 3 | export class MaybeWeakMap { 4 | private readonly weakMap = new WeakMap(); 5 | private readonly map = new Map(); 6 | 7 | private getMapForKey(key: K): Map { 8 | if (isPrimitive(key)) { 9 | return this.map; 10 | } else { 11 | return this.weakMap as Map; 12 | } 13 | } 14 | 15 | get(key: K): V | undefined { 16 | return this.getMapForKey(key).get(key); 17 | } 18 | 19 | set(key: K, value: V): this { 20 | this.getMapForKey(key).set(key, value); 21 | return this; 22 | } 23 | 24 | has(key: K): boolean { 25 | return this.getMapForKey(key).has(key); 26 | } 27 | 28 | delete(key: K): boolean { 29 | return this.getMapForKey(key).delete(key); 30 | } 31 | 32 | clear(): void { 33 | this.map.clear(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /stylings/src/utils/maybeArray.ts: -------------------------------------------------------------------------------- 1 | export type MaybeArray = T | T[]; 2 | 3 | export function convertMaybeArrayToArray(maybeArray: MaybeArray): T[] { 4 | if (Array.isArray(maybeArray)) { 5 | return [...maybeArray]; 6 | } 7 | 8 | return [maybeArray]; 9 | } 10 | -------------------------------------------------------------------------------- /stylings/src/utils/maybeValue.ts: -------------------------------------------------------------------------------- 1 | export const NO_VALUE_SYMBOL = Symbol("NO_VALUE"); 2 | 3 | export type MaybeValue = T | typeof NO_VALUE_SYMBOL; 4 | 5 | export function getHasValue(value: MaybeValue): value is T { 6 | return value !== NO_VALUE_SYMBOL; 7 | } 8 | 9 | export function maybeValue(initial: MaybeValue = NO_VALUE_SYMBOL) { 10 | return initial; 11 | } 12 | -------------------------------------------------------------------------------- /stylings/src/utils/memoize.ts: -------------------------------------------------------------------------------- 1 | import { DeepMap, MapLikeContructor } from "./map/DeepMap"; 2 | 3 | import { HashMap } from "./map/HashMap"; 4 | import { MaybeWeakMap } from "./map/MaybeWeakMap"; 5 | 6 | type MemoizeKeysMode = "maybeWeak" | "default" | "weak" | "hash"; 7 | 8 | interface MemoizeOptions { 9 | mode?: MemoizeKeysMode; 10 | } 11 | 12 | function getMapToUse(mode?: MemoizeKeysMode): MapLikeContructor { 13 | if (mode === "default") return Map; 14 | if (mode === "maybeWeak") return MaybeWeakMap; 15 | if (mode === "hash") return HashMap; 16 | if (mode === "weak") return WeakMap; 17 | 18 | return Map; 19 | } 20 | 21 | type AnyFunction = (...args: any[]) => any; 22 | 23 | interface MemoizedFunctionExtra { 24 | remove: (...args: Parameters) => void; 25 | } 26 | 27 | export type MemoizedFunction = F & MemoizedFunctionExtra; 28 | 29 | /** 30 | * Lodash memoize is based on serialization and is only using first arguments as cache keys 31 | */ 32 | export function memoizeFn(callback: F, options?: MemoizeOptions): MemoizedFunction { 33 | type A = Parameters; 34 | type R = ReturnType; 35 | const deepMap = new DeepMap(getMapToUse(options?.mode)); 36 | 37 | const getMemoized = (...args: A): R => { 38 | const result = deepMap.getOrCreateCallback(callback, ...args); 39 | 40 | return result; 41 | }; 42 | 43 | const remove = (...args: A): void => { 44 | deepMap.delete(args); 45 | }; 46 | 47 | getMemoized.remove = remove; 48 | 49 | return getMemoized as MemoizedFunction; 50 | } 51 | -------------------------------------------------------------------------------- /stylings/src/utils/nestedRecord.ts: -------------------------------------------------------------------------------- 1 | type PropertiesMapValue = unknown | NestedRecord; 2 | 3 | type NestedRecord = { 4 | [key: string]: PropertiesMapValue; 5 | }; 6 | 7 | export type PropertiesMap = Map; 8 | 9 | /** 10 | * Returns true only for plain, {} objects (not instances of classes, arrays, etc.) 11 | */ 12 | function getIsPlainObject(value: unknown): value is Record { 13 | return value?.constructor === Object; 14 | } 15 | 16 | function getPath(currentPath: string, key: string) { 17 | if (!currentPath) return key; 18 | 19 | return `${currentPath}.${key}`; 20 | } 21 | 22 | function buildPropertiesMap(currentPath: string, result: PropertiesMap, input: NestedRecord) { 23 | for (const [key, value] of Object.entries(input)) { 24 | const path = getPath(currentPath, key); 25 | 26 | if (getIsPlainObject(value)) { 27 | buildPropertiesMap(path, result, value); 28 | } else { 29 | result.set(path, value); 30 | } 31 | } 32 | } 33 | 34 | export function createNestedRecordPropertiesMap(input: NestedRecord): PropertiesMap { 35 | const map = new Map(); 36 | 37 | buildPropertiesMap("", map, input); 38 | 39 | return map; 40 | } 41 | 42 | function innerMapNestedRecord( 43 | currentPath: string, 44 | input: NestedRecord, 45 | mapper: (value: unknown, path: string) => unknown, 46 | ): NestedRecord { 47 | const result: NestedRecord = {}; 48 | 49 | for (const [key, value] of Object.entries(input)) { 50 | const path = getPath(currentPath, key); 51 | 52 | if (getIsPlainObject(value)) { 53 | result[key] = innerMapNestedRecord(path, value, mapper); 54 | } else { 55 | result[key] = mapper(value, path); 56 | } 57 | } 58 | 59 | return result; 60 | } 61 | 62 | export function mapNestedRecord(input: NestedRecord, mapper: (value: unknown, path: string) => unknown): NestedRecord { 63 | return innerMapNestedRecord("", input, mapper); 64 | } 65 | -------------------------------------------------------------------------------- /stylings/src/utils/nullish.ts: -------------------------------------------------------------------------------- 1 | export type MaybeUndefined = T | undefined; 2 | 3 | export type Nullish = null | undefined; 4 | 5 | export function isNotNullish(input: T | Nullish): input is T { 6 | return !isNullish(input); 7 | } 8 | 9 | export const isDefined = isNotNullish; 10 | 11 | export function isNullish(input: unknown): input is Nullish { 12 | return input === null || input === undefined; 13 | } 14 | 15 | export type Falsy = false | 0 | "" | null | undefined; 16 | 17 | export type MaybeFalsy = T | Falsy; 18 | 19 | export function isNotFalsy(input: T | Falsy): input is T { 20 | return !isFalsy(input); 21 | } 22 | 23 | export function isFalsy(input: unknown): input is Falsy { 24 | return !input; 25 | } 26 | -------------------------------------------------------------------------------- /stylings/src/utils/objectHash.ts: -------------------------------------------------------------------------------- 1 | import { getObjectId } from "./objectId"; 2 | 3 | // String hashing function 4 | function hashString(str: string): number { 5 | let hash = 0; 6 | 7 | const length = str.length; 8 | 9 | for (let i = 0; i < length; i++) { 10 | // hash * 31 + char 11 | hash = ((hash << 5) - hash) ^ str.charCodeAt(i); 12 | } 13 | return hash >>> 0; // Convert to unsigned 32-bit integer 14 | } 15 | 16 | /** 17 | * Enhanced JSON hash function that can handle any object. 18 | * Uses objectKey for non-plain objects. 19 | */ 20 | export function getObjectHash(obj: T): number { 21 | return getValueHash(obj) >>> 0; 22 | } 23 | 24 | const UNDEFINED_HASH = hashString("undefined"); 25 | const NULL_HASH = hashString("null"); 26 | const NUMBER_HASH = hashString("number"); 27 | const BOOLEAN_HASH = hashString("boolean"); 28 | const STRING_HASH = hashString("string"); 29 | const SYMBOL_HASH = hashString("symbol"); 30 | const FUNCTION_HASH = hashString("function"); 31 | const ARRAY_HASH = hashString("array"); 32 | const OBJECT_HASH = hashString("object"); 33 | const BIGINT_HASH = hashString("bigint"); 34 | 35 | // Handle any value type for hashing 36 | function getValueHash(value: any): number { 37 | const type = typeof value; 38 | 39 | switch (type) { 40 | case "undefined": 41 | return UNDEFINED_HASH; 42 | case "object": { 43 | if (value === null) return NULL_HASH; 44 | break; 45 | } 46 | case "number": 47 | return NUMBER_HASH + ~~value * 31; 48 | case "boolean": 49 | return BOOLEAN_HASH + (value ? 1231 : 1237); 50 | case "string": 51 | return STRING_HASH + hashString(value); 52 | case "symbol": 53 | return SYMBOL_HASH + getObjectId(value as symbol); 54 | case "function": 55 | return FUNCTION_HASH + getObjectId(value as Function); 56 | case "bigint": 57 | return BIGINT_HASH; 58 | } 59 | 60 | if (Array.isArray(value)) { 61 | let hash = ARRAY_HASH + value.length; 62 | for (const item of value) { 63 | hash = hash * 31 + getValueHash(item); 64 | } 65 | 66 | return hash; 67 | } 68 | 69 | // Check if it's a plain object or an instance of a class 70 | if (Object.getPrototypeOf(value) !== Object.prototype) { 71 | const nameHash = hashString(Object.getPrototypeOf(value).constructor.name); 72 | // Non-plain object - use the objectKey function 73 | return nameHash + getObjectId(value) * 31; 74 | } 75 | 76 | // Plain object - proceed with normal hashing 77 | let hash = OBJECT_HASH; 78 | 79 | for (const key in value) { 80 | hash += hashString(key) + getValueHash(value[key]); 81 | } 82 | 83 | return hash; 84 | } 85 | -------------------------------------------------------------------------------- /stylings/src/utils/objectId.ts: -------------------------------------------------------------------------------- 1 | let id = 1; 2 | 3 | const objectIdMap = new WeakMap(); 4 | 5 | export function getObjectId(obj: T): number { 6 | const cached = objectIdMap.get(obj); 7 | 8 | if (cached) return cached; 9 | 10 | const newId = id++; 11 | 12 | objectIdMap.set(obj, newId); 13 | 14 | return newId; 15 | } 16 | -------------------------------------------------------------------------------- /stylings/src/utils/primitive.ts: -------------------------------------------------------------------------------- 1 | export type Primitive = string | number | boolean | undefined | null; 2 | 3 | export function isPrimitive(value: unknown): value is Primitive { 4 | const type = typeof value; 5 | 6 | return type === "string" || type === "number" || type === "boolean" || value === undefined || value === null; 7 | } 8 | -------------------------------------------------------------------------------- /stylings/src/utils/react/internals.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { FunctionComponent } from "react"; 4 | 5 | function getReactInternals() { 6 | return Reflect.get(React, "__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE"); 7 | } 8 | 9 | interface ReactFiber { 10 | return: ReactFiber | null; 11 | type: any; 12 | } 13 | 14 | export const REACT_INTERNALS = getReactInternals(); 15 | 16 | function getCurrentOwner() { 17 | const fiber = REACT_INTERNALS?.A?.getOwner?.() as ReactFiber | null; 18 | 19 | if (!fiber) return null; 20 | 21 | return fiber; 22 | } 23 | 24 | function getIsFunctionalComponent(input: any): input is FunctionComponent { 25 | return typeof input === "function"; 26 | } 27 | 28 | export function getFunctionalComponentsStack() { 29 | try { 30 | let owner = getCurrentOwner(); 31 | 32 | if (!owner) return null; 33 | 34 | const stack: FunctionComponent[] = []; 35 | 36 | while (owner) { 37 | if (getIsFunctionalComponent(owner.type)) { 38 | stack.push(owner.type); 39 | } 40 | owner = owner.return; 41 | } 42 | 43 | return stack; 44 | } catch (error) { 45 | return null; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /stylings/src/utils/registry.ts: -------------------------------------------------------------------------------- 1 | import { FunctionComponent } from "react"; 2 | 3 | const inlineComponents = new WeakSet(); 4 | 5 | export function registerStylesComponent(component: FunctionComponent) { 6 | inlineComponents.add(component); 7 | } 8 | 9 | export function getIsStylesComponent(component: FunctionComponent) { 10 | return inlineComponents.has(component); 11 | } 12 | -------------------------------------------------------------------------------- /stylings/src/utils/reuse.ts: -------------------------------------------------------------------------------- 1 | import { HashMap } from "./map/HashMap"; 2 | 3 | export function createValueReuser() { 4 | const map = new HashMap(); 5 | 6 | return function getReused(value: I): I { 7 | if (map.has(value)) return map.get(value) as I; 8 | 9 | map.set(value, value); 10 | 11 | return value; 12 | }; 13 | } 14 | 15 | export type ValueReuser = ReturnType>; 16 | -------------------------------------------------------------------------------- /stylings/src/utils/updateValue.ts: -------------------------------------------------------------------------------- 1 | export type ValueUpdater = Partial | ((value: T) => void); 2 | 3 | export function produceValue(value: T, updater: ValueUpdater): T { 4 | if (typeof updater === "function") { 5 | const clone = structuredClone(value); 6 | updater(clone); 7 | return clone; 8 | } 9 | 10 | return { ...value, ...updater }; 11 | } 12 | 13 | export function updateValue(value: T, updater: ValueUpdater): void { 14 | if (typeof updater === "function") { 15 | updater(value); 16 | } else { 17 | Object.assign(value as {}, updater); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /stylings/tests/animation.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { $animation } from "@"; 4 | 5 | describe("animation", () => { 6 | test("basic", () => { 7 | expect($animation.fadeIn.property("transform-y", [0, 1])()).toMatchInlineSnapshot(` 8 | [ 9 | "animation-name: ", 10 | e { 11 | "id": "sc-keyframes-gNInrH", 12 | "inject": [Function], 13 | "name": "gNInrH", 14 | "rules": "0% { opacity: 0; 15 | transform: translate(var(--animate_x_0, 0), var(--animate_y_0, 0)); 16 | } 100% { opacity: 1; 17 | transform: translate(var(--animate_x_100, 0), var(--animate_y_100, 0)); 18 | } ", 19 | }, 20 | ";", 21 | "animation-duration: 150ms;", 22 | "animation-timing-function: ease-in-out;", 23 | "will-change: transform, opacity;", 24 | "--animate_y_0: 0;", 25 | "--animate_y_100: 1;", 26 | ] 27 | `); 28 | }); 29 | 30 | test("complex composer is memoized", () => { 31 | const composer1 = $animation.fadeIn.property("transform-y", [0, 1]); 32 | const composer2 = $animation.fadeIn.property("transform-y", [0, 1]); 33 | 34 | expect(composer1).toBe(composer2); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /stylings/tests/flex.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { $flex } from "@"; 4 | 5 | describe("flex", () => { 6 | test("basic", () => { 7 | expect($flex.vertical()).toMatchInlineSnapshot(` 8 | [ 9 | "display: flex;", 10 | "flex-direction: column;", 11 | ] 12 | `); 13 | }); 14 | 15 | test("gap", () => { 16 | expect($flex.vertical.gap(2)()).toMatchInlineSnapshot(` 17 | [ 18 | "display: flex;", 19 | "flex-direction: column;", 20 | "gap: 1rem;", 21 | ] 22 | `); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /stylings/tests/hash.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { getObjectHash } from "@/utils/objectHash"; 4 | 5 | const hash = getObjectHash; 6 | 7 | class Foo {} 8 | 9 | describe("hash", () => { 10 | test("basic", () => { 11 | expect(hash({ a: 1 })).toBe(hash({ a: 1 })); 12 | expect(hash({ a: 1 })).not.toBe(hash({ a: 2 })); 13 | expect(hash({ a: 1 })).not.toBe(hash({ b: 1 })); 14 | expect(hash({ a: 1 })).not.toBe(hash({ a: 1, b: 2 })); 15 | 16 | const foo = new Foo(); 17 | const bar = new Foo(); 18 | 19 | expect(hash(foo)).toBe(hash(foo)); 20 | expect(hash({ foo })).toBe(hash({ foo })); 21 | expect(hash({ foo })).not.toBe(hash({ bar })); 22 | expect(hash({ foo })).not.toBe(hash({ foo: bar })); 23 | 24 | expect(hash({})).not.toBe(hash(null)); 25 | expect(hash(null)).not.toBe(hash(undefined)); 26 | 27 | expect(hash([foo])).toBe(hash([foo])); 28 | expect(hash([foo])).not.toBe(hash([bar])); 29 | expect(hash([foo, foo])).not.toBe(hash([foo, bar])); 30 | expect(hash([foo, foo])).toBe(hash([foo, foo])); 31 | }); 32 | 33 | test("iteration", () => { 34 | const previous = new Map(); 35 | 36 | for (let i = 0; i < 10000; i++) { 37 | const string = `gap: ${i}px;`; 38 | const hashValue = hash(string); 39 | 40 | const previousValue = previous.get(hashValue); 41 | 42 | if (previousValue) { 43 | console.info({ 44 | previousValue, 45 | string, 46 | previousHash: hash(previousValue), 47 | currentHash: hash(string), 48 | }); 49 | } 50 | 51 | expect(previous.get(hashValue)).toBeUndefined(); 52 | 53 | previous.set(hashValue, string); 54 | } 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /stylings/tests/theme.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | $color, 3 | $flex, 4 | $font, 5 | composeThemeVariants, 6 | createTheme, 7 | createThemeVariant, 8 | getIsTheme, 9 | getIsThemeOrVariant, 10 | getIsThemeVariant, 11 | getIsThemedComposer, 12 | } from "@"; 13 | import { describe, expect, test } from "vitest"; 14 | 15 | const $theme = createTheme({ 16 | foo: 42, 17 | typo: { 18 | base: $font.size("1rem"), 19 | }, 20 | colors: { 21 | primary: $color({ color: "red" }), 22 | }, 23 | }); 24 | 25 | const $blue = createThemeVariant($theme, { 26 | foo: 43, 27 | colors: { 28 | primary: $color({ color: "blue" }), 29 | }, 30 | }); 31 | 32 | describe("theme", () => { 33 | test("detect theme and variant", () => { 34 | expect(getIsThemeOrVariant($theme)).toBe(true); 35 | expect(getIsThemeOrVariant($blue)).toBe(true); 36 | 37 | expect(getIsTheme($theme)).toBe(true); 38 | expect(getIsThemeVariant($theme)).toBe(false); 39 | 40 | expect(getIsThemeVariant($blue)).toBe(true); 41 | expect(getIsTheme($blue)).toBe(false); 42 | 43 | expect(getIsTheme(null)).toBe(false); 44 | expect(getIsThemeVariant(null)).toBe(false); 45 | }); 46 | 47 | test("converts composers to themed composers", () => { 48 | expect(getIsThemedComposer($theme.typo.base)).toBe(true); 49 | expect(getIsThemedComposer($theme.colors.primary)).toBe(true); 50 | }); 51 | 52 | test("chaining keeps composers as themed", () => { 53 | expect(getIsThemedComposer($theme.typo.base.underline.capitalize)).toBe(true); 54 | }); 55 | 56 | test("creating variant is not allowed on non theme", () => { 57 | expect(() => { 58 | // @ts-expect-error 59 | createThemeVariant($blue, {}); 60 | }).toThrowErrorMatchingInlineSnapshot(`[Error: Can only create theme variant from source theme]`); 61 | }); 62 | 63 | test("primitive values are returned basing on theme", () => { 64 | expect($theme.foo()).toBe(42); 65 | 66 | expect($theme.foo($theme)).toBe(42); 67 | expect($theme.foo($blue)).toBe(43); 68 | 69 | expect($theme.foo({ $theme })).toBe(42); 70 | expect($theme.foo({ theme: $blue })).toBe(43); 71 | 72 | expect($theme.foo({ theme: undefined })).toBe(42); 73 | }); 74 | 75 | test("correctly return theme variant for proper call", () => { 76 | expect($theme.colors.primary({ $theme })).toMatchInlineSnapshot(` 77 | [ 78 | "red", 79 | ] 80 | `); 81 | 82 | expect($theme.colors.primary({ theme: $blue })).toMatchInlineSnapshot(` 83 | [ 84 | "blue", 85 | ] 86 | `); 87 | }); 88 | 89 | test("correctly return default theme variant for call without theme", () => { 90 | expect($theme.colors.primary()).toMatchInlineSnapshot(` 91 | [ 92 | "red", 93 | ] 94 | `); 95 | 96 | expect($theme.colors.primary.asBg()).toMatchInlineSnapshot(` 97 | [ 98 | "background-color: red; --background-color: red;", 99 | ] 100 | `); 101 | }); 102 | 103 | test("correctly return theme chained value for props call", () => { 104 | expect($theme.colors.primary.asBg({ $theme })).toMatchInlineSnapshot(` 105 | [ 106 | "background-color: red; --background-color: red;", 107 | ] 108 | `); 109 | 110 | expect($theme.colors.primary.asBg({ theme: $blue })).toMatchInlineSnapshot(` 111 | [ 112 | "background-color: blue; --background-color: blue;", 113 | ] 114 | `); 115 | }); 116 | 117 | test("correctly return theme chained value for theme", () => { 118 | expect($theme.colors.primary.asBg($theme)).toMatchInlineSnapshot(` 119 | [ 120 | "background-color: red; --background-color: red;", 121 | ] 122 | `); 123 | 124 | expect($theme.colors.primary.asBg($blue)).toMatchInlineSnapshot(` 125 | [ 126 | "background-color: blue; --background-color: blue;", 127 | ] 128 | `); 129 | }); 130 | 131 | test("correctly passes arguments to themed composers", () => { 132 | expect($theme.colors.primary.opacity(0.5).asBg($theme)).toMatchInlineSnapshot(` 133 | [ 134 | "background-color: hsla(0, 100%, 50%, 0.5); --background-color: hsla(0, 100%, 50%, 0.5);", 135 | ] 136 | `); 137 | 138 | expect($theme.colors.primary.opacity(0.5).asBg($blue)).toMatchInlineSnapshot(` 139 | [ 140 | "background-color: hsla(240, 100%, 50%, 0.5); --background-color: hsla(240, 100%, 50%, 0.5);", 141 | ] 142 | `); 143 | }); 144 | 145 | test("uses default theme if no theme is provided", () => { 146 | expect($theme.colors.primary.asBg({ theme: undefined })).toMatchInlineSnapshot(` 147 | [ 148 | "background-color: red; --background-color: red;", 149 | ] 150 | `); 151 | }); 152 | 153 | test("throws error if theme is not composable", () => { 154 | expect(() => $theme.colors.primary.asBg({ theme: {} })).toThrowErrorMatchingInlineSnapshot( 155 | `[Error: There is some value provided as theme in props, but it is has unknown type]`, 156 | ); 157 | }); 158 | 159 | test("returns value from original theme if variant does not change it", () => { 160 | expect($theme.typo.base({ theme: $blue })).toMatchInlineSnapshot(` 161 | [ 162 | "font-size: 1rem;", 163 | ] 164 | `); 165 | 166 | expect($theme.typo.base.underline({ theme: $blue })).toMatchInlineSnapshot(` 167 | [ 168 | "font-size: 1rem;", 169 | "text-decoration: underline;", 170 | ] 171 | `); 172 | }); 173 | 174 | test("will throw if theme exits composable context", () => { 175 | const theme = createTheme({ 176 | color: $color({ color: "red" }), 177 | }); 178 | 179 | expect(() => { 180 | // @ts-expect-error 181 | theme.color.compile()(); 182 | }).toThrowErrorMatchingInlineSnapshot( 183 | `[Error: Failed to get theme value.]`, 184 | ); 185 | }); 186 | 187 | test("composing variants", () => { 188 | const $theme = createTheme({ 189 | color: $color({ color: "red" }), 190 | width: 100, 191 | }); 192 | 193 | const blue = createThemeVariant($theme, { 194 | color: $color({ color: "blue" }), 195 | }); 196 | 197 | const wide = createThemeVariant($theme, { 198 | width: 200, 199 | }); 200 | 201 | const $all = composeThemeVariants($theme, [blue, wide]); 202 | const $blueOnly = composeThemeVariants($theme, [blue]); 203 | const $wideOnly = composeThemeVariants($theme, [wide]); 204 | 205 | expect($theme.color()).toMatchInlineSnapshot(` 206 | [ 207 | "red", 208 | ] 209 | `); 210 | 211 | expect($theme.color($all)).toMatchInlineSnapshot(` 212 | [ 213 | "blue", 214 | ] 215 | `); 216 | 217 | expect($theme.color($blueOnly)).toMatchInlineSnapshot(` 218 | [ 219 | "blue", 220 | ] 221 | `); 222 | 223 | expect($theme.color($wideOnly)).toMatchInlineSnapshot(` 224 | [ 225 | "red", 226 | ] 227 | `); 228 | 229 | expect($theme.width()).toMatchInlineSnapshot(`100`); 230 | expect($theme.width($all)).toMatchInlineSnapshot(`200`); 231 | expect($theme.width($blueOnly)).toMatchInlineSnapshot(`100`); 232 | expect($theme.width($wideOnly)).toMatchInlineSnapshot(`200`); 233 | }); 234 | 235 | test("cache", () => { 236 | expect($theme.colors.primary).toBe($theme.colors.primary); 237 | 238 | expect($theme.colors.primary.hover).toBe($theme.colors.primary.hover); 239 | 240 | expect($theme.colors.primary.hover.asBg.asOutline).toBe($theme.colors.primary.hover.asBg.asOutline); 241 | 242 | expect($theme.colors.primary.hover.asBg.asOutline()).toBe($theme.colors.primary.hover.asBg.asOutline()); 243 | 244 | expect($theme.colors.primary.hover({ $theme })).toBe($theme.colors.primary.hover({ $theme })); 245 | 246 | expect($theme.colors.primary.hover({ theme: $blue })).toBe($theme.colors.primary.hover({ theme: $blue })); 247 | 248 | expect($theme.colors.primary.hover($theme)).toBe($theme.colors.primary.hover($theme)); 249 | 250 | expect($theme.colors.primary.hover($blue)).toBe($theme.colors.primary.hover($blue)); 251 | 252 | expect($theme.colors.primary.hover()).toBe($theme.colors.primary.hover()); 253 | 254 | expect($theme.colors.primary.opacity(0.5).hover()).toBe($theme.colors.primary.opacity(0.5).hover()); 255 | 256 | expect( 257 | // 258 | $theme.colors.primary.addStyle({ accentColor: "red" }).hover(), 259 | ).toBe( 260 | // 261 | $theme.colors.primary.addStyle({ accentColor: "red" }).hover(), 262 | ); 263 | }); 264 | 265 | test("cache with configs", () => { 266 | const { $flex: themedFlex } = createTheme({ 267 | $flex: $flex, 268 | }); 269 | 270 | // expect(themedFlex.gap(2).vertical.alignCenter.justifyAround).toBe( 271 | // themedFlex.gap(2).vertical.alignCenter.justifyAround, 272 | // ); 273 | 274 | expect(themedFlex.gap(2).vertical.alignCenter.justifyAround()).toBe( 275 | themedFlex.gap(2).vertical.alignCenter.justifyAround(), 276 | ); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /stylings/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "exclude": ["demo"] 8 | } 9 | -------------------------------------------------------------------------------- /stylings/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["ESNext", "DOM"], 7 | "allowJs": true, 8 | "jsx": "react-jsx", 9 | "moduleResolution": "Node", 10 | "strict": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "esModuleInterop": true, 14 | "noEmit": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noImplicitReturns": true, 18 | "skipLibCheck": true, 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["src/*"], 22 | "@": ["src"] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /stylings/vite.config.build.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import dts from "vite-plugin-dts"; 3 | import fs from "fs"; 4 | import { resolve } from "path"; 5 | 6 | function collectExternalDependencies() { 7 | const packageJson = fs.readFileSync(resolve(__dirname, "package.json"), "utf8"); 8 | const dependencies = JSON.parse(packageJson).dependencies; 9 | const peerDependencies = JSON.parse(packageJson).peerDependencies; 10 | return Object.keys(dependencies).concat(Object.keys(peerDependencies)); 11 | } 12 | 13 | export default defineConfig({ 14 | build: { 15 | lib: { 16 | formats: ["es", "cjs"], 17 | entry: resolve(__dirname, "src", "index.ts"), 18 | fileName: (format) => `index.${format}.js`, 19 | }, 20 | outDir: resolve(__dirname, "dist"), 21 | rollupOptions: { 22 | external: collectExternalDependencies(), 23 | }, 24 | minify: false, 25 | emptyOutDir: true, 26 | sourcemap: false, 27 | }, 28 | plugins: [dts({ tsconfigPath: resolve(__dirname, "tsconfig.build.json"), rollupTypes: true })], 29 | }); 30 | -------------------------------------------------------------------------------- /stylings/vite.config.demo.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { resolve } from "path"; 3 | 4 | export default defineConfig({ 5 | root: resolve(__dirname, "demo"), 6 | resolve: { 7 | alias: { 8 | "@": resolve(__dirname, "src"), 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /stylings/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import { resolve } from "path"; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | "@": resolve(__dirname, "src"), 8 | }, 9 | }, 10 | test: { 11 | // ... 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://openapi.vercel.sh/vercel.json", 3 | "version": 2, 4 | "buildCommand": "yarn build", 5 | "outputDirectory": ".next", 6 | "installCommand": "yarn install", 7 | "framework": "nextjs" 8 | } 9 | --------------------------------------------------------------------------------