├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── babel.config.js ├── changelog.md ├── jest.config.json ├── migration-guide.md ├── package-lock.json ├── package.json ├── readme.md ├── src ├── UtilityParser.ts ├── __tests__ │ ├── arbitrary-breakpoint-prefixes.spec.ts │ ├── aspect-ratio.spec.ts │ ├── borders.spec.ts │ ├── color-scheme.spec.tsx │ ├── colors.spec.ts │ ├── custom-utils.spec.ts │ ├── dark-mode.spec.ts │ ├── flex.spec.ts │ ├── font-size.spec.ts │ ├── inset.spec.ts │ ├── letter-spacing.spec.ts │ ├── margin.spec.ts │ ├── memo-buster.spec.tsx │ ├── min-max-dims.spec.ts │ ├── prefix-match.spec.ts │ ├── screens.spec.ts │ ├── shadow.spec.ts │ ├── simple-mappings.spec.ts │ ├── transform.spec.ts │ ├── tw.spec.ts │ ├── width-height.spec.ts │ └── z-index.spec.ts ├── cache.ts ├── create.ts ├── helpers.ts ├── hooks.ts ├── index.ts ├── parse-inputs.ts ├── plugin.ts ├── resolve │ ├── borders.ts │ ├── color.ts │ ├── flex.ts │ ├── font-family.ts │ ├── font-size.ts │ ├── inset.ts │ ├── letter-spacing.ts │ ├── line-height.ts │ ├── opacity.ts │ ├── shadow.ts │ ├── spacing.ts │ ├── transform.ts │ └── width-height.ts ├── screens.ts ├── styles.ts ├── tw-config.ts └── types.ts ├── supported-utilities.md ├── tsconfig.cjs.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [`eslint:recommended`, `plugin:@typescript-eslint/recommended`, `prettier`], 4 | ignorePatterns: [`**/dist/*`], 5 | parser: `@typescript-eslint/parser`, 6 | parserOptions: { 7 | ecmaVersion: `latest`, 8 | sourceType: `module`, 9 | }, 10 | plugins: [`@typescript-eslint`, `import`, `no-only-tests`], 11 | rules: { 12 | 'no-var': `off`, 13 | 'no-console': `error`, 14 | 'prefer-const': [`error`, { destructuring: `all` }], 15 | 'default-case': `off`, 16 | 'no-only-tests/no-only-tests': `error`, 17 | 'no-useless-constructor': `off`, 18 | '@typescript-eslint/no-non-null-assertion': `error`, 19 | '@typescript-eslint/no-namespace': `off`, 20 | '@typescript-eslint/no-empty-function': `off`, 21 | '@typescript-eslint/ban-ts-comment': `off`, 22 | '@typescript-eslint/consistent-type-imports': `error`, 23 | '@typescript-eslint/no-useless-constructor': `error`, 24 | '@typescript-eslint/no-explicit-any': `off`, 25 | '@typescript-eslint/no-this-alias': `off`, 26 | '@typescript-eslint/no-angle-bracket-type-assertion': `off`, 27 | '@typescript-eslint/no-use-before-define': `off`, 28 | '@typescript-eslint/explicit-module-boundary-types': [ 29 | `error`, 30 | { allowArgumentsExplicitlyTypedAsAny: true }, 31 | ], 32 | '@typescript-eslint/no-parameter-properties': `off`, 33 | '@typescript-eslint/no-unused-vars': [`error`, { argsIgnorePattern: `^_` }], 34 | 'no-unused-vars': `off`, 35 | 'no-undef': `off`, 36 | camelcase: `off`, 37 | '@typescript-eslint/quotes': [`error`, `backtick`], 38 | '@typescript-eslint/explicit-function-return-type': [ 39 | `error`, 40 | { 41 | allowExpressions: true, 42 | allowTypedFunctionExpressions: true, 43 | allowHigherOrderFunctions: true, 44 | }, 45 | ], 46 | 'no-unreachable-loop': `error`, 47 | 'no-useless-backreference': `error`, 48 | 'require-atomic-updates': `error`, 49 | 50 | // taken from eslint-config-react-app 51 | 'array-callback-return': `error`, 52 | 'dot-location': [`error`, `property`], 53 | eqeqeq: [`error`, `smart`], 54 | 'new-parens': `error`, 55 | 'no-array-constructor': `error`, 56 | 'no-caller': `error`, 57 | 'no-cond-assign': [`error`, `except-parens`], 58 | 'no-const-assign': `error`, 59 | 'no-control-regex': `error`, 60 | 'no-delete-var': `error`, 61 | 'no-dupe-args': `error`, 62 | 'no-dupe-class-members': `error`, 63 | 'no-dupe-keys': `error`, 64 | 'no-duplicate-case': `error`, 65 | 'no-empty-character-class': `error`, 66 | 'no-empty-pattern': `error`, 67 | 'no-eval': `error`, 68 | 'no-ex-assign': `error`, 69 | 'no-extend-native': `error`, 70 | 'no-extra-bind': `error`, 71 | 'no-extra-label': `error`, 72 | 'no-fallthrough': `error`, 73 | 'no-func-assign': `error`, 74 | 'no-implied-eval': `error`, 75 | 'no-invalid-regexp': `error`, 76 | 'no-iterator': `error`, 77 | 'no-label-var': `error`, 78 | 'no-labels': [`error`, { allowLoop: true, allowSwitch: false }], 79 | 'no-lone-blocks': `error`, 80 | 'no-mixed-operators': [ 81 | `error`, 82 | { 83 | groups: [ 84 | [`&`, `|`, `^`, `~`, `<<`, `>>`, `>>>`], 85 | [`==`, `!=`, `===`, `!==`, `>`, `>=`, `<`, `<=`], 86 | [`&&`, `||`], 87 | [`in`, `instanceof`], 88 | ], 89 | allowSamePrecedence: false, 90 | }, 91 | ], 92 | 'no-multi-str': `error`, 93 | 'no-native-reassign': `error`, 94 | 'no-negated-in-lhs': `error`, 95 | 'no-new-func': `error`, 96 | 'no-new-object': `error`, 97 | 'no-new-symbol': `error`, 98 | 'no-new-wrappers': `error`, 99 | 'no-obj-calls': `error`, 100 | 'no-octal': `error`, 101 | 'no-octal-escape': `error`, 102 | 'no-regex-spaces': `error`, 103 | 'no-restricted-syntax': [`error`, `WithStatement`], 104 | 'no-script-url': `error`, 105 | 'no-self-assign': `error`, 106 | 'no-self-compare': `error`, 107 | 'no-sequences': `error`, 108 | 'no-shadow-restricted-names': `error`, 109 | 'no-sparse-arrays': `error`, 110 | 'no-template-curly-in-string': `error`, 111 | 'no-this-before-super': `error`, 112 | 'no-throw-literal': `error`, 113 | 'no-unreachable': `error`, 114 | 'no-unused-expressions': [ 115 | `error`, 116 | { 117 | allowShortCircuit: true, 118 | allowTernary: true, 119 | allowTaggedTemplates: true, 120 | }, 121 | ], 122 | 'no-unused-labels': `error`, 123 | 'no-useless-computed-key': `error`, 124 | 'no-useless-concat': `error`, 125 | 'no-useless-escape': `error`, 126 | 'no-useless-rename': [ 127 | `error`, 128 | { 129 | ignoreDestructuring: false, 130 | ignoreImport: false, 131 | ignoreExport: false, 132 | }, 133 | ], 134 | 'no-with': `error`, 135 | 'no-whitespace-before-property': `error`, 136 | 'require-yield': `error`, 137 | 'rest-spread-spacing': [`error`, `never`], 138 | strict: [`error`, `never`], 139 | 'unicode-bom': [`error`, `never`], 140 | 'use-isnan': `error`, 141 | 'valid-typeof': `error`, 142 | 'getter-return': `error`, 143 | 144 | // import 145 | 'import/order': [ 146 | `error`, 147 | { groups: [`builtin`, `external`, `type`, `parent`, `sibling`, `index`] }, 148 | ], 149 | 'import/first': `error`, 150 | 'import/no-amd': `error`, 151 | }, 152 | }; 153 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: basic-ci 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | ci: 9 | name: test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: bahmutov/npm-install@v1 14 | with: 15 | install-command: npm i --legacy-peer-deps 16 | - name: ts 17 | run: npm run ts:check 18 | - name: compile 19 | run: npm run compile 20 | - name: check commonjs node compat 21 | run: node ./dist/cjs/create.js 22 | - name: lint 23 | run: npm run lint 24 | - name: test 25 | run: npm run test 26 | - name: prettier 27 | run: npm run format:check 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | dist/ 3 | node_modules/ 4 | jest.config.js 5 | *.swp 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | jest.config.json 4 | src/ 5 | *.swp 6 | *.md 7 | tsconfig.json 8 | tsconfig.cjs.json 9 | babel.config.js 10 | .prettierignore 11 | .prettierrc.json 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | scripts-prepend-node-path=true 2 | 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 90, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "proseWrap": "always" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "node_modules/": true, 4 | "dist/": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [`module:metro-react-native-babel-preset`], 3 | }; 4 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. The format is based 4 | on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to 5 | [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | [comment]: # 'Section Titles: Added/Fixed/Changed/Removed' 8 | 9 | ## [4.8.0] - 2025-05-27 10 | 11 | - support arbitrary hsl colors and opacity modifiers (thanks @cpotdevin) 12 | [(#347)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/347) 13 | 14 | ## [4.7.0] - 2025-05-06 15 | 16 | - support transform scale, rotate, skew, translate (thanks @Pedrozxcv) 17 | [(#343)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/343) 18 | 19 | ## [4.6.1] - 2025-01-24 20 | 21 | - prevent auto-upgrades to `tailwindcss@4.x.x` until we explore/fix compitibility 22 | [(#331)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/331) 23 | 24 | ## [4.6.0] - 2024-11-07 25 | 26 | - added support for fontWeight sub-customization in fontSize theming (thanks @Gyeop) 27 | [(#324)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/324) 28 | 29 | ## [4.5.1] - 2024-08-23 30 | 31 | - revised minimum RN version to 0.62.2 after testing, see 32 | [(#308)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/308) 33 | 34 | ## [4.5.0] - 2024-07-22 35 | 36 | - added support for `size-*` shorthand utility 37 | [(#314)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/314) and 38 | [(#315)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/315) 39 | 40 | ## [4.4.0] - 2024-07-09 41 | 42 | - added support for arbitrary named colors (eg. `text-[lemonchiffon]`) 43 | [(#306)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/306) and 44 | [(#309)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/309) 45 | 46 | ## [4.3.0] - 2024-06-18 47 | 48 | - added support for line-height shorthand with font-size (eg. `text-sm/6`) 49 | [(#282)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/292) and 50 | [(#293)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/293) 51 | 52 | ## [4.2.0] - 2024-03-22 53 | 54 | ### Added 55 | 56 | - added support for arbitrary viewport spacing (eg. `mx-[10vh]`) 57 | [(#285)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/285) and 58 | [(#287)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/287) 59 | - added support for arbitrary flex-grow/shrink syntax (eg. `grow-[7]`) 60 | [(#146)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/146) and 61 | [(#287)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/287) 62 | 63 | ## [4.1.0] - 2024-03-06 64 | 65 | ### Added 66 | 67 | - added Android-only text vertical align (thanks @menghany) 68 | [(#284)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/284) 69 | 70 | ## [4.0.2] - 2024-03-04 71 | 72 | ### Fixed 73 | 74 | - fixed reading of app color scheme from multiple, nested components (thanks @crjc) 75 | [(#281)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/281) and 76 | [(#283)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/283) 77 | 78 | ## [4.0.1] - 2024-02-26 79 | 80 | ### Fixed 81 | 82 | - when duplicate utilities, last now wins 83 | [(#245)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/245) and 84 | [(#279)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/279) 85 | - resolve unusual color values from config in `tw.color()` 86 | [(#273)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/273) and 87 | [(#280)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/280) 88 | 89 | ## [4.0.0] - 2024-02-15 90 | 91 | > See [migration guide](./migration-guide.md) for upgrading instructions. 92 | 93 | ### Added 94 | 95 | - `tw.memoBuster` property for breaking memoization, [see here](./readme.md#memo-busting) 96 | for more details. 97 | 98 | ### Fixed 99 | 100 | - media-query off by one pixel error 101 | [(#223)](https://github.com/jaredh159/tailwind-react-native-classnames/issues/223) 102 | - initialization of color scheme when managing color scheme manually (not listening to 103 | device changes) (see 104 | [#266](https://github.com/jaredh159/tailwind-react-native-classnames/pull/266)) 105 | 106 | ### Changed 107 | 108 | - `useDeviceContext()` options when opting-out of listening to device color scheme changes 109 | (see [migration-guide](./migration-guide.md)) 110 | - `useAppColorScheme()` no longer allows initial value, moved to `useDeviceContext()` (see 111 | [migration guide](./migration-guide.md) and 112 | [#266](https://github.com/jaredh159/tailwind-react-native-classnames/pull/266)) 113 | - media-query minimum (see 114 | [#223](https://github.com/jaredh159/tailwind-react-native-classnames/issues/223)) 115 | 116 | ## [3.6.9] - 2024-02-12 117 | 118 | ### Fixed 119 | 120 | - edge case with dark-mode and color opacity shorthands 121 | [(#269)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/269) 122 | 123 | ## [3.6.8] - 2024-01-17 124 | 125 | ### Fixed 126 | 127 | - support TS `moduleResolution: "NodeNext"` w/ `types` export 128 | [(#263)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/263) 129 | 130 | ### Changed 131 | 132 | - update dev-only deps and config 133 | 134 | ## [3.6.7] - 2023-12-17 135 | 136 | ### Fixed 137 | 138 | - fix breakpoint/prefix resolution of string-based custom utilities 139 | [(#259)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/259) 140 | 141 | ## [3.6.6] - 2023-12-11 142 | 143 | ### Fixed 144 | 145 | - handle negative z-index utilities (e.g. `-z-30`) 146 | [(#258)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/258) 147 | 148 | ## [3.6.5] - 2023-11-29 149 | 150 | ### Fixed 151 | 152 | - export documented `style` fn 153 | [(#255)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/255) 154 | 155 | ### Changed 156 | 157 | - add test for media-query custom utility 158 | [(#255)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/255) 159 | - codestyle: switch to explicit TS `type` imports 160 | [(#255)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/255) 161 | 162 | ## [3.6.4] - 2023-08-09 163 | 164 | ### Changed 165 | 166 | - perf: ensure cached utilities referrentially equal to prevent re-renders 167 | [(#241)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/241) 168 | 169 | ## [3.6.3] - 2023-07-19 170 | 171 | ### Fixed 172 | 173 | - support inset `auto` utilities (e.g. `top-auto`) 174 | [(#237)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/237) 175 | 176 | ## [3.6.2] - 2023-07-11 177 | 178 | ### Changed 179 | 180 | - chore: support leading dots in custom utilities to improve intellisense 181 | [(#236)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/236) 182 | - docs: add intellisense instructions to readme 183 | [(#228)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/228) 184 | - docs: add expo dark mode note to readme 185 | [(#229)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/229) 186 | 187 | ## [3.6.1] - 2023-05-22 188 | 189 | ### Fixed 190 | 191 | - fix ordering/cache issue with utility prefixes 192 | [(#227)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/227) 193 | 194 | ## [3.6.0] - 2023-01-18 195 | 196 | ### Added 197 | 198 | - support flex-gap, newly supported in RN 0.71 199 | [(#212)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/212) 200 | 201 | ## [3.5.0] - 2022-12-12 202 | 203 | ### Added 204 | 205 | - support flex-basis 206 | [(#204)](https://github.com/jaredh159/tailwind-react-native-classnames/pull/204) 207 | 208 | --- 209 | 210 | [...more older releases, not documented here (yet)](https://github.com/jaredh159/tailwind-react-native-classnames/commits/master/?after=d3716f6549bfd0c392c8e00cf8a9892ba34e41ea+34) 211 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "react-native", 3 | "transform": { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | "testRegex": "__tests__/.*spec\\.tsx?$" 7 | } 8 | -------------------------------------------------------------------------------- /migration-guide.md: -------------------------------------------------------------------------------- 1 | # Migrating to 4.x.x 2 | 3 | ## 🚨 Breaking Changes 4 | 5 | ### Breakpoint Boundaries 6 | 7 | Prior to `v4.0.0` `twrnc` displayed subtly different behavior for media query ranges from 8 | TailwindCSS. Specifically, TailwindCSS media queries are **inclusive** of the _minimum_, 9 | and older versions of `twrnc` were **exclusive** of the range minimum. In practical terms 10 | that means that a utility like `md:bg-black` is applicable in TailwindCSS _when the screen 11 | size is **exactly** `768px` wide,_ whereas in `twrnc@3.x.x` that class would only begin to 12 | apply at **`769px`.** Version `4.0.0` corrects this off-by-one error, making the library 13 | more consistent with TailwindCSS. 14 | 15 | We think that this will not affect most library consumers, but it is possible that you 16 | could see a difference in appearance if your device window size is precisely the same as a 17 | media query range minimum, so this is technically a breaking change. 18 | 19 | If you'd like to restore the prior behavior, you can customize your theme's `screens`, 20 | settings: 21 | 22 | ```js 23 | module.exports = { 24 | theme: { 25 | screens: { 26 | sm: '641px', 27 | md: '769px', 28 | lg: '1025px', 29 | xl: '1281px', 30 | }, 31 | }, 32 | }; 33 | ``` 34 | 35 | ### `useAppColorScheme()` Initialization 36 | 37 | _NB: If you were not using dark mode, or were only observing the device's color scheme 38 | (which is the default and most common), you can ignore this section._ 39 | 40 | The mechanism for opting-out of listening to device color scheme changes in order to 41 | _control color scheme manually_ from your app has changed in `v4.0.0`. First, 42 | `useAppColorScheme()` no longer takes a second parameter for initialization: 43 | 44 | ```diff 45 | -const [colorScheme, ...] = useAppColorScheme(tw, `light`); 46 | +const [colorScheme, ...] = useAppColorScheme(tw); 47 | ``` 48 | 49 | This means that `useAppColorScheme()` is now safe to use multiple times in your app, 50 | anywhere you need to read or modify the app color scheme. As part of this change the 51 | **initialization has moved** to `useDeviceContext()` (which should only ever be called 52 | once, at the root of the app): 53 | 54 | ```diff 55 | useDeviceContext(tw, { 56 | - withDeviceColorScheme: false, 57 | + observeDeviceColorSchemeChanges: false, 58 | + initialColorScheme: "light", 59 | }); 60 | ``` 61 | 62 | The value for `initialColorScheme` can be `"light"`, `"dark"`, or `"device"`. `device` 63 | means initialize to the _current_ color scheme of the device one time before the app 64 | assumes control. 65 | 66 | _Please note:_ there was a bug in `v3.x.x` when omitting the optional initialization param 67 | (now removed) passed to `useAppColorScheme()` that caused the color scheme to not be 68 | correctly initialized when the device was in **dark mode**. Version `4.x.x` fixes this 69 | issue, but the bugfix can result in an observable difference in your app's initialization 70 | for users whose devices are set to dark mode. If you want to replicate the former behavior 71 | before the bug was fixed, you should explicitly pass `"light"` for `initialColorScheme` 72 | when calling `useDeviceContext()`. 73 | 74 | ## 💃 New Features 75 | 76 | The main `tw` object now exposes a `.memoBuster` string property, which can be useful for 77 | resolving some simple memoization re-render failure issues. See 78 | [here for more](./readme.md#memo-busting). 79 | 80 | # Migrating to 3.x.x 81 | 82 | **Color renames**. In line with the 83 | [upgrade guide](https://tailwindcss.com/docs/upgrade-guide#removed-color-aliases), 84 | tailwind v3 has mapped `green`, `yellow`, and `purple` to their extended colors. 85 | Additionally, 86 | [gray colors](https://tailwindcss.com/docs/upgrade-guide#renamed-gray-scales) were renamed 87 | in the extended colors to be more specific. Both of these can be resolved by following 88 | tailwind's upgrade guide and optionally re-aliasing the colors in your 89 | `tailwind.config.js`. 90 | 91 | Other than checking on any changes caused by color renames in tailwindcss, there are no 92 | breaking changes in v3 of this library, no further changes should be necessary. 93 | 94 | New v3 prefixes and classes are being added as we identify use cases. If you do have a 95 | feature that would help your development, please 96 | [open an issue](https://github.com/jaredh159/tailwind-react-native-classnames/issues/new) 97 | and include any libraries / hooks that could help someone in the community put a PR 98 | together. 99 | 100 | # Migrating to 2.x.x 101 | 102 | **1.** During the rewrite, the package name on npm was changed to `twrnc`. To remove the 103 | old library and install v2, run: 104 | 105 | ``` 106 | npm uninstall tailwind-react-native-classnames 107 | npm install twrnc 108 | ``` 109 | 110 | **2.** Grep through your project replacing `from 'tailwind-react-native-classnames'` with 111 | `from 'twrnc'`. 112 | 113 | **3.** If you were using a `tailwind.config.js` you can `git rm` your `tw-rn-styles.json` 114 | file, and switch to passing your config directly to `create` as shown below: (details 115 | [here](#customization)) 116 | 117 | ```js 118 | const tw = create(require(`../../tailwind.config.js`)); 119 | ``` 120 | 121 | That's it! 🎉 The core API and functionality should work exactly the same from v1 to v2. 122 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twrnc", 3 | "version": "4.8.0", 4 | "description": "simple, expressive API for tailwindcss + react-native", 5 | "author": "Jared Henderson ", 6 | "module": "dist/esm/index.js", 7 | "types": "dist/esm/index.d.ts", 8 | "main": "dist/cjs/index.js", 9 | "exports": { 10 | "./package.json": "./package.json", 11 | ".": { 12 | "types": "./dist/esm/index.d.ts", 13 | "import": "./dist/esm/index.js", 14 | "require": "./dist/cjs/index.js" 15 | }, 16 | "./create": { 17 | "types": "./dist/esm/create.d.ts", 18 | "import": "./dist/esm/create.js", 19 | "require": "./dist/cjs/create.js" 20 | } 21 | }, 22 | "typesVersions": { 23 | "*": { 24 | "create": [ 25 | "./dist/esm/create.d.ts" 26 | ] 27 | } 28 | }, 29 | "license": "MIT", 30 | "scripts": { 31 | "test": "jest", 32 | "test:watch": "jest --watch", 33 | "test:clear-cache": "jest --clearCache", 34 | "lint": "eslint . --ext .ts,.tsx", 35 | "lint:fix": "eslint . --ext .ts,.tsx --fix", 36 | "ts:check": "tsc --noEmit", 37 | "format": "prettier --write .", 38 | "format:check": "prettier --check .", 39 | "compile": "npm run compile:esm && npm run compile:cjs", 40 | "compile:esm": "tsc", 41 | "compile:cjs": "tsc --project tsconfig.cjs.json", 42 | "prepublishOnly": "npm run compile", 43 | "npub:precheck": "npm run lint && npm run format:check && npm run ts:check && npm run test" 44 | }, 45 | "dependencies": { 46 | "tailwindcss": ">=2.0.0 <4.0.0" 47 | }, 48 | "peerDependencies": { 49 | "react-native": ">=0.62.2" 50 | }, 51 | "devDependencies": { 52 | "@babel/preset-typescript": "^7.23.3", 53 | "@types/jest": "^29.5.11", 54 | "@types/react": "^18.2.55", 55 | "@types/react-native": "^0.73.0", 56 | "@types/react-test-renderer": "^18.0.7", 57 | "@types/tailwindcss": "^3.1.0", 58 | "@typescript-eslint/eslint-plugin": "^6.15.0", 59 | "@typescript-eslint/parser": "^6.15.0", 60 | "eslint": "^8.56.0", 61 | "eslint-config-prettier": "^9.1.0", 62 | "eslint-plugin-import": "^2.29.1", 63 | "eslint-plugin-no-only-tests": "^3.1.0", 64 | "jest": "^29.7.0", 65 | "metro-react-native-babel-preset": "^0.66.2", 66 | "prettier": "^3.1.1", 67 | "react": "^18.2.0", 68 | "react-native": "^0.73.4", 69 | "react-test-renderer": "^18.2.0", 70 | "ts-jest": "^29.1.1", 71 | "typescript": "^5.3.3" 72 | }, 73 | "homepage": "https://github.com/jaredh159/tailwind-react-native-classnames", 74 | "repository": { 75 | "type": "git", 76 | "url": "https://github.com/jaredh159/tailwind-react-native-classnames.git" 77 | }, 78 | "bugs": { 79 | "url": "https://github.com/jaredh159/tailwind-react-native-classnames/issues" 80 | }, 81 | "keywords": [ 82 | "tailwind", 83 | "tailwindcss", 84 | "react-native", 85 | "classnames" 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Tailwind React Native Classnames 🏄‍♂️ 2 | 3 | > A simple, expressive API for TailwindCSS + React Native, written in TypeScript 4 | 5 | ```jsx 6 | import { View, Text } from 'react-native'; 7 | import tw from 'twrnc'; 8 | 9 | const MyComponent = () => ( 10 | 11 | Hello World 12 | 13 | ); 14 | ``` 15 | 16 | ## Features 🚀 17 | 18 | - full support for all _native_ RN styles with tailwind counterparts: 19 | ([view](https://reactnative.dev/docs/view-style-props), 20 | [layout](https://reactnative.dev/docs/layout-props), 21 | [image](https://reactnative.dev/docs/image-style-props), 22 | [shadow](https://reactnative.dev/docs/shadow-props), and 23 | [text](https://reactnative.dev/docs/text-style-props)). 24 | - very fast: best performance of all RN styling libraries, according to 25 | [this benchmark](https://github.com/efstathiosntonas/react-native-style-libraries-benchmark). 26 | - compatible with Tailwind CSS v3 and v2 27 | - respects your `tailwind.config.js` for full configuration 28 | - platform prefixes: `android:mt-4 ios:mt-2` 29 | - dark mode support: `bg-white dark:bg-black` 30 | - media query support: `w-48 lg:w-64` (also, arbitrary: `min-w-[600px]:flex-wrap`) 31 | - device orientation prefixes: `portrait:flex-col landscape:flex-row` 32 | - `vw` and `vh` unit support: `h-screen`, `min-w-screen`, `w-[25vw]`, etc... 33 | - `retina` device pixel density prefix: `w-4 retina:w-2` 34 | - arbitrary, JIT-style classes: `mt-[31px] bg-[#eaeaea] text-red-200/75`, etc... 35 | - tagged template literal syntax for most common usage 36 | - merges supplied RN style objects for unsupported utilities or complex use cases 37 | - supports custom utility creation via standard 38 | [plugin config](https://tailwindcss.com/docs/adding-new-utilities#using-a-plugin). 39 | - flexible, conditional styles based on 40 | [classnames package api](https://github.com/JedWatson/classnames). 41 | - written 100% in Typescript, ships with types 42 | 43 | ## Docs: 44 | 45 | - [Installation](#installation) 46 | - [API](#api) 47 | - [Customization](#customization) 48 | - [Enabling Device-Context Prefixes](#enabling-device-context-prefixes) 49 | - [Taking Control of Dark Mode](#taking-control-of-dark-mode) 50 | - [Customizing Breakpoints](#customizing-breakpoints) 51 | - [Adding Custom Classes](#adding-custom-classes) 52 | - [Matching Conditional Prefixes](#matching-conditional-prefixes) 53 | - [Box-Shadows](#box-shadows) 54 | - [RN-Only Additions](#rn-only-additions) 55 | - [JIT-style Arbitrary Values](#jit-style-arbitrary-values) 56 | - [VS Code Intellisense](#vs-code-intellisense) 57 | - [Memo-Busting](#memo-busting) 58 | - [Migrating from previous versions](#migrating-from-previous-versions) 59 | - [Prior Art](#prior-art) 60 | 61 | ## Installation 62 | 63 | ```bash 64 | npm install twrnc 65 | ``` 66 | 67 | ## API 68 | 69 | The default export is an ES6 _Tagged template function_ which is nice and terse for the 70 | most common use case -- passing a bunch of space-separated Tailwind classes and getting 71 | back a react-native style object: 72 | 73 | ```js 74 | import tw from 'twrnc'; 75 | 76 | tw`pt-6 bg-blue-100`; 77 | // -> { paddingTop: 24, backgroundColor: 'rgba(219, 234, 254, 1)' } 78 | ``` 79 | 80 | In the spirit of Tailwindcss's intuitive responsive prefix syntax, `twrnc` adds support 81 | for **platform prefixes** to conditionally apply styles based on the current platform: 82 | 83 | ```js 84 | // 😎 styles only added if platform matches 85 | tw`ios:pt-4 android:pt-2`; 86 | ``` 87 | 88 | Media query-like breakpoint prefixes supported (see [Breakpoints](#breakpoints) for 89 | configuration): 90 | 91 | ```js 92 | // 😎 faux media queries 93 | tw`flex-col lg:flex-row`; 94 | ``` 95 | 96 | Dark mode support (see [here](#enabling-device-context-prefixes) for configuration); 97 | 98 | ```js 99 | // 😎 dark mode support 100 | tw`bg-white dark:bg-black`; 101 | ``` 102 | 103 | You can also use `tw.style()` for handling more complex class name declarations. The api 104 | for this function is directly taken from the excellent 105 | [classnames](https://github.com/JedWatson/classnames#readme) package. 106 | 107 | ```js 108 | // pass multiple args 109 | tw.style('text-sm', 'bg-blue-100', 'flex-row mb-2'); 110 | 111 | // arrays of classnames work too 112 | tw.style(['text-sm', 'bg-blue-100']); 113 | 114 | // falsy stuff is ignored, so you can do conditionals like this 115 | tw.style(isOpen && 'bg-blue-100'); 116 | 117 | // { [className]: boolean } style - key class only added if value is `true` 118 | tw.style({ 119 | 'bg-blue-100': isActive, 120 | 'text-red-500': invalid, 121 | }); 122 | 123 | // or, combine tailwind classes with plain react-native style object: 124 | tw.style('bg-blue-100', { resizeMode: `repeat` }); 125 | 126 | // mix and match input styles as much as you want 127 | tw.style('bg-blue-100', ['flex-row'], { 'text-xs': true }, { fontSize: 9 }); 128 | ``` 129 | 130 | If you need some styling that is not supported in a utility class, or just want to do some 131 | custom run-time logic, you can **pass raw RN style objects** to `tw.style()`, and they get 132 | merged in with the styles generated from any other utility classes: 133 | 134 | ```js 135 | tw.style(`mt-1`, { 136 | resizeMode: `repeat`, 137 | width: `${progress}%`, 138 | }); 139 | // -> { marginTop: 4, resizeMode: 'repeat', width: '32%' } 140 | ``` 141 | 142 | The `tw` function also has a method `color` that can be used to get back a string value of 143 | a tailwind color. Especially useful if you're using a customized color pallette. 144 | 145 | ```js 146 | tw.color('blue-100'); // `bg|text|border-blue-100` also work 147 | // -> "rgba(219, 234, 254, 1)" 148 | ``` 149 | 150 | You can import the main `tw` function and reach for `tw.style` only when you need it: 151 | 152 | ```jsx 153 | import tw from 'twrnc'; 154 | 155 | const MyComponent = () => ( 156 | 157 | Hello 158 | 159 | ); 160 | ``` 161 | 162 | ...or if the tagged template function isn't your cup of tea, just import `tw.style` as 163 | `tw`: 164 | 165 | ```jsx 166 | import { style as tw } from 'twrnc'; 167 | 168 | const MyComponent = () => ( 169 | 170 | ); 171 | ``` 172 | 173 | ## Customization 174 | 175 | You can use `twrnc` right out of the box if you haven't customized your 176 | `tailwind.config.js` file at all. But more likely you've got some important app-specific 177 | tailwind customizations you'd like to use. For that reason, we expose the ability to 178 | create a **custom configured version** of the `tw` function object. 179 | 180 | ```js 181 | // lib/tailwind.js 182 | import { create } from 'twrnc'; 183 | 184 | // create the customized version... 185 | const tw = create(require(`../../tailwind.config.js`)); // <- your path may differ 186 | 187 | // ... and then this becomes the main function your app uses 188 | export default tw; 189 | ``` 190 | 191 | ...and in your component files import your own customized version of the function instead: 192 | 193 | ```jsx 194 | // SomeComponent.js 195 | import tw from './lib/tailwind'; 196 | ``` 197 | 198 | > ⚠️ Make sure to use `module.exports = {}` instead of `export default {}` in your 199 | > `tailwind.config.js` file, as the latter is not supported. 200 | 201 | ## Enabling Device-Context Prefixes 202 | 203 | To enable prefixes that require runtime device data, like _dark mode_, and _screen size 204 | breakpoints_, etc., you need to connect the `tw` function with a dynamic source of device 205 | context information. The library exports a React hook called `useDeviceContext` that takes 206 | care of this for you. It should be included **one time**, at the _root of your component 207 | hierarchy,_ as shown below: 208 | 209 | ```js 210 | import tw from './lib/tailwind'; // or, if no custom config: `from 'twrnc'` 211 | import { useDeviceContext } from 'twrnc'; 212 | 213 | export default function App() { 214 | useDeviceContext(tw); // <- 👋 215 | return ( 216 | 217 | Hello 218 | 219 | ); 220 | } 221 | ``` 222 | 223 | > ⚠️ If you're using Expo, make sure to make the following change in `app.json` to use the 224 | > `dark:` prefix as Expo by default locks your app to light mode only. 225 | 226 | ```json 227 | { 228 | "expo": { 229 | "userInterfaceStyle": "automatic" 230 | } 231 | } 232 | ``` 233 | 234 | ## Taking Control of Dark Mode 235 | 236 | By default, if you use `useDeviceContext()` as outlined above, your app will respond to 237 | ambient changes in the _device's color scheme_ (set in system preferences). If you'd 238 | prefer to **explicitly control** the color scheme of your app with some in-app mechanism, 239 | you'll need to configure things slightly differently: 240 | 241 | ```js 242 | import { useDeviceContext, useAppColorScheme } from 'twrnc'; 243 | 244 | export default function App() { 245 | useDeviceContext(tw, { 246 | // 1️⃣ opt OUT of listening to DEVICE color scheme events 247 | observeDeviceColorSchemeChanges: false 248 | // 2️⃣ and supply an initial color scheme 249 | initialColorScheme: `light`, // 'light' | 'dark' | 'device' 250 | }); 251 | 252 | // 3️⃣ use the `useAppColorScheme` hook anywhere to get a reference to the current 253 | // colorscheme, with functions to modify it (triggering re-renders) when you need 254 | const [colorScheme, toggleColorScheme, setColorScheme] = useAppColorScheme(tw); 255 | 256 | return ( 257 | {/* 4️⃣ use one of the setter functions, like `toggleColorScheme` in your app */} 258 | 259 | Switch Color Scheme 260 | 261 | ); 262 | } 263 | ``` 264 | 265 | ## Customizing Breakpoints 266 | 267 | You can **customize the breakpoints** in the same way as a 268 | [tailwindcss web project](https://tailwindcss.com/docs/breakpoints), using 269 | `tailwind.config.js`. The defaults that ship with `tailwindcss` are geared towards the 270 | web, so you likely want to set your own for device sizes you're interested in, like this: 271 | 272 | ```js 273 | // tailwind.config.js 274 | module.exports = { 275 | theme: { 276 | screens: { 277 | sm: '380px', 278 | md: '420px', 279 | lg: '680px', 280 | // or maybe name them after devices for `tablet:flex-row` 281 | tablet: '1024px', 282 | }, 283 | }, 284 | }; 285 | ``` 286 | 287 | ## Adding Custom Classes 288 | 289 | To add custom utilities, use the 290 | [plugin method](https://tailwindcss.com/docs/adding-new-utilities#using-a-plugin) 291 | described in the tailwind docs, instead of writing to a `.css` file. 292 | 293 | ```js 294 | const plugin = require('tailwindcss/plugin'); 295 | 296 | module.exports = { 297 | plugins: [ 298 | plugin(({ addUtilities }) => { 299 | addUtilities({ 300 | '.btn': { 301 | padding: 3, 302 | borderRadius: 10, 303 | textTransform: `uppercase`, 304 | backgroundColor: `#333`, 305 | }, 306 | '.resize-repeat': { 307 | resizeMode: `repeat`, 308 | }, 309 | }); 310 | }), 311 | ], 312 | }; 313 | ``` 314 | 315 | Wil also allow you to supply a **string** of other utility classes (similar to `@apply`), 316 | instead of using **CSS-in-JS** style objects: 317 | 318 | ```js 319 | module.exports = { 320 | plugins: [ 321 | plugin(({ addUtilities }) => { 322 | addUtilities({ 323 | // 😎 similar to `@apply` 324 | '.btn': `px-4 py-1 rounded-full bg-red-800 text-white`, 325 | '.body-text': `font-serif leading-relaxed tracking-wide text-gray-800`, 326 | }); 327 | }), 328 | ], 329 | }; 330 | ``` 331 | 332 | ## Matching Conditional Prefixes 333 | 334 | `twrnc` also exposes a `tw.prefixMatch(...prefixes: string[]) => boolean` function that 335 | allows you to test whether a given prefix (or combination of prefixes) would produce a 336 | style given the current device context. This can be useful when you need to pass some 337 | primitive value to a component, and wish you could leverage `tw`'s knowledge of the 338 | current device, or really anywhere you just need to do some logic based on the device 339 | context. This could also be accomplished by importing `Platform` or a combination of other 340 | RN hooks, but chances are you've already imported your `tw` function, and this saves you 341 | re-implementing all that logic on your own: 342 | 343 | ```tsx 344 | const SomeComponent = () => ( 345 | 346 | ; 347 | {tw.prefixMatch(`ios`, `dark`) ? : } 348 | 349 | ); 350 | ``` 351 | 352 | ## Box Shadows 353 | 354 | Box shadows [in CSS](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow#syntax) 355 | differ substantially from [shadow in RN](https://reactnative.dev/docs/shadow-props), so 356 | this library doesn't attempt to parse CSS box-shadow strings and translate them into RN 357 | style objects. Instead, it offers a number of low-level utilities not present in 358 | `tailwindcss`, which map to the 359 | [4 shadow props](https://reactnative.dev/docs/shadow-props) in RN: 360 | 361 | ```js 362 | // RN `shadowColor` 363 | tw`shadow-white`; // > { shadowColor: `#fff` } 364 | tw`shadow-red-200`; // > { shadowColor: `#fff` } 365 | tw`shadow-[#eaeaea]`; // > { shadowColor: `#eaeaea` } 366 | tw`shadow-black shadow-opacity-50`; // > { shadowColor: `rgba(0,0,0,0.5)` } 367 | 368 | // RN `shadowOffset` 369 | tw`shadow-offset-1`; // > { shadowOffset: { width: 4, height: 4 } } 370 | tw`shadow-offset-2/3`; // > { shadowOffset: { width: 8, height: 12 } } 371 | tw`shadow-offset-[3px]`; // > { shadowOffset: { width: 3, height: 3 } }], 372 | tw`shadow-offset-[4px]/[5px]`; // > { shadowOffset: { width: 4, height: 5 } }], 373 | 374 | // RN `shadowOpacity` 375 | tw`shadow-opacity-50`; // { shadowOpacity: 0.5 } 376 | 377 | // RN `shadowRadius` 378 | tw`shadow-radius-1`; // { shadowRadius: 4 } 379 | tw`shadow-radius-[10px]`; // { shadowRadius: 10 } 380 | ``` 381 | 382 | We also provide a _default implementation_ of the `shadow-` utils 383 | [provided by tailwindcss](https://tailwindcss.com/docs/box-shadow), so you can use: 384 | 385 | ```js 386 | tw`shadow-md`; 387 | /* 388 | -> { 389 | shadowOffset: { width: 1, height: 1 }, 390 | shadowColor: `#000`, 391 | shadowRadius: 3, 392 | shadowOpacity: 0.125, 393 | elevation: 3, 394 | } 395 | */ 396 | ``` 397 | 398 | To override the default implementations of these named shadow classes, 399 | [add your own custom utilities](#adding-custom-classes) -- any custom utilities you 400 | provide with the same names will override the ones this library ships with. 401 | 402 | ## RN-Only Additions 403 | 404 | `twrnc` implements all of the tailwind utilities which overlap with supported RN (native, 405 | not web) style props. But it also adds a sprinkling of RN-only utilities which don't map 406 | to web-css, including: 407 | 408 | - [low-level shadow utilities](#box-shadows) 409 | - [elevation](https://reactnative.dev/docs/view-style-props#elevation-android) (android 410 | only), eg: `elevation-1`, `elevation-4` 411 | - `small-caps` -> `{fontVariant: 'small-caps'}` 412 | - number based font-weight utilities `font-100`, `font-400`, (100...900) 413 | - `direction-(inherit|ltr|rtl)` 414 | - `align-self: baseline;` via `self-baseline` 415 | - `include-font-padding` and `remove-font-padding` (android only: `includeFontPadding`) 416 | - image tint color control (`tint-{color}` e.g. `tint-red-200`) 417 | 418 | ## JIT-Style Arbitrary Values 419 | 420 | Many of the arbitrary-style utilities made possible by Tailwind JIT are implemented in 421 | `twrnc`, including: 422 | 423 | - arbitrary colors: `bg-[#f0f]`, `text-[rgb(33,45,55)]` 424 | - negative values: `-mt-4`, `-tracking-[2px]` 425 | - shorthand color opacity: `text-red-200/75` (`red-200` at `75%` opacity) 426 | - merging color/opacity: `border-black border-opacity-75` 427 | - arbitrary opacity amounts: `opacity-73` 428 | - custom spacing: `mt-[4px]`, `-pb-[3px]`, `tracking-[2px]` 429 | - arbitrary fractional insets: `bottom-7/9`, `left-5/8` 430 | - arbitrary min/max width/height: `min-w-[40%]`, `max-h-3/8`, `w-[25vw]`, `h-[21px]` 431 | - arbitrary breakpoints: `min-w-[600px]:flex-row`, `max-h-[1200px]:p-4` 432 | 433 | Not every utility currently supports all variations of arbitrary values, so if you come 434 | across one you feel is missing, open an issue or a PR. 435 | 436 | ## VS Code Intellisense 437 | 438 | Add the following to the settings of the 439 | [official Tailwind plugin](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) 440 | for VS Code. 441 | 442 | ```jsonc 443 | // ... 444 | "tailwindCSS.classAttributes": [ 445 | // ... 446 | "style" 447 | ], 448 | "tailwindCSS.classFunctions": ["tw", "tw.color", "tw.style"], 449 | ``` 450 | 451 | More detailed instructions, including how to add snippets, are available 452 | [here](https://github.com/jaredh159/tailwind-react-native-classnames/discussions/124). 453 | 454 | ## Memo Busting 455 | 456 | If you're using device-context prefixes (like `dark:`, and `md:`), _memoized_ components 457 | can cause problems by preventing re-renders when the color scheme or window size changes. 458 | You may not be memoizing explicitly yourself as many third-party libraries (like 459 | `react-navigation`) memoizes their own components. 460 | 461 | In order to help with this problem, `twrnc` exposes a `.memoBuster` property on the `tw` 462 | object. This string property is meant to passed as a `key` prop to break memoization 463 | boundaries. It is stable (preventing re-renders) until something in the device context 464 | changes, at which point it deterministically updates: 465 | 466 | ```tsx 467 | 468 | ``` 469 | 470 | > This is not a perfect solution for **all** memoization issues. For caveats and more 471 | > context, see 472 | > [#112](https://github.com/jaredh159/tailwind-react-native-classnames/issues/112). 473 | 474 | ## Migrating from Previous Versions 475 | 476 | See [migration-guide.md](/migration-guide.md). 477 | 478 | ## Prior Art 479 | 480 | - The first version of this package (before it was re-written from scratch) was based 481 | heavily on the excellent 482 | [vadimdemedes/tailwind-rn](https://github.com/vadimdemedes/tailwind-rn). 483 | - The flexible `tw.style()` api was taken outright from 484 | [classnames](https://github.com/JedWatson/classnames#readme) 485 | - [TailwindCSS](https://tailwindcss.com) 486 | - [Tailwind JIT](https://tailwindcss.com/docs/just-in-time-mode) 487 | - [React Native](https://reactnative.dev) 488 | -------------------------------------------------------------------------------- /src/UtilityParser.ts: -------------------------------------------------------------------------------- 1 | import type { TwConfig } from './tw-config'; 2 | import type { StyleIR, DeviceContext, ParseContext, Platform } from './types'; 3 | import type Cache from './cache'; 4 | import fontSize from './resolve/font-size'; 5 | import lineHeight from './resolve/line-height'; 6 | import spacing from './resolve/spacing'; 7 | import screens from './screens'; 8 | import { isOrientation, isPlatform } from './types'; 9 | import fontFamily from './resolve/font-family'; 10 | import { color, colorOpacity } from './resolve/color'; 11 | import { border, borderRadius } from './resolve/borders'; 12 | import * as h from './helpers'; 13 | import { inset } from './resolve/inset'; 14 | import { flexGrowShrink, flexBasis, flex, gap } from './resolve/flex'; 15 | import { widthHeight, size, minMaxWidthHeight } from './resolve/width-height'; 16 | import { letterSpacing } from './resolve/letter-spacing'; 17 | import { opacity } from './resolve/opacity'; 18 | import { shadowOpacity, shadowOffset } from './resolve/shadow'; 19 | import { rotate, scale, skew, transformNone, translate } from './resolve/transform'; 20 | 21 | export default class UtilityParser { 22 | private position = 0; 23 | private string: string; 24 | private char?: string; 25 | private order?: number; 26 | private isNull = false; 27 | private isNegative = false; 28 | private context: ParseContext = {}; 29 | 30 | public constructor( 31 | input: string, 32 | private config: TwConfig = {}, 33 | private cache: Cache, 34 | device: DeviceContext, 35 | platform: Platform, 36 | ) { 37 | this.context.device = device; 38 | const parts = input.trim().split(`:`); 39 | let prefixes: string[] = []; 40 | if (parts.length === 1) { 41 | this.string = input; 42 | } else { 43 | this.string = parts.pop() ?? ``; 44 | prefixes = parts; 45 | } 46 | this.char = this.string[0]; 47 | this.parsePrefixes(prefixes, device, platform); 48 | } 49 | 50 | public parse(): StyleIR { 51 | if (this.isNull) { 52 | return { kind: `null` }; 53 | } 54 | 55 | // resolve things like ios:hidden, after prefix removed 56 | const cached = this.cache.getIr(this.rest); 57 | if (cached) { 58 | if (this.order !== undefined) { 59 | return { kind: `ordered`, order: this.order, styleIr: cached }; 60 | } 61 | return cached; 62 | } 63 | 64 | this.parseIsNegative(); 65 | const ir = this.parseUtility(); 66 | if (!ir) { 67 | return { kind: `null` }; 68 | } 69 | 70 | if (this.order !== undefined) { 71 | return { kind: `ordered`, order: this.order, styleIr: ir }; 72 | } 73 | 74 | return ir; 75 | } 76 | 77 | private parseUtility(): StyleIR | null { 78 | const theme = this.config.theme; 79 | let style: StyleIR | null = null; 80 | 81 | switch (this.char) { 82 | case `m`: 83 | case `p`: { 84 | const match = this.peekSlice(1, 3).match(/^(t|b|r|l|x|y)?-/); 85 | if (match) { 86 | const prop = this.char === `m` ? `margin` : `padding`; 87 | this.advance((match[0]?.length ?? 0) + 1); 88 | const spacingDirection = h.getDirection(match[1]); 89 | const style = spacing( 90 | prop, 91 | spacingDirection, 92 | this.rest, 93 | this.context, 94 | this.config.theme?.[prop], 95 | ); 96 | if (style) return style; 97 | } 98 | } 99 | } 100 | 101 | if (this.consumePeeked(`h-`)) { 102 | style = widthHeight(`height`, this.rest, this.context, theme?.height); 103 | if (style) return style; 104 | } 105 | 106 | if (this.consumePeeked(`w-`)) { 107 | style = widthHeight(`width`, this.rest, this.context, theme?.width); 108 | if (style) return style; 109 | } 110 | 111 | if (this.consumePeeked(`min-w-`)) { 112 | style = minMaxWidthHeight(`minWidth`, this.rest, this.context, theme?.minWidth); 113 | if (style) return style; 114 | } 115 | 116 | if (this.consumePeeked(`min-h-`)) { 117 | style = minMaxWidthHeight(`minHeight`, this.rest, this.context, theme?.minHeight); 118 | if (style) return style; 119 | } 120 | 121 | if (this.consumePeeked(`max-w-`)) { 122 | style = minMaxWidthHeight(`maxWidth`, this.rest, this.context, theme?.maxWidth); 123 | if (style) return style; 124 | } 125 | 126 | if (this.consumePeeked(`max-h-`)) { 127 | style = minMaxWidthHeight(`maxHeight`, this.rest, this.context, theme?.maxHeight); 128 | if (style) return style; 129 | } 130 | 131 | if (this.consumePeeked(`leading-`)) { 132 | style = lineHeight(this.rest, theme?.lineHeight); 133 | if (style) return style; 134 | } 135 | 136 | if (this.consumePeeked(`text-`)) { 137 | style = fontSize(this.rest, theme, this.context); 138 | if (style) return style; 139 | 140 | style = color(`text`, this.rest, theme?.textColor); 141 | if (style) return style; 142 | 143 | if (this.consumePeeked(`opacity-`)) { 144 | style = colorOpacity(`text`, this.rest); 145 | if (style) return style; 146 | } 147 | } 148 | 149 | if (this.consumePeeked(`font-`)) { 150 | style = fontFamily(this.rest, theme?.fontFamily); 151 | if (style) return style; 152 | } 153 | 154 | if (this.consumePeeked(`aspect-`)) { 155 | if (this.consumePeeked(`ratio-`)) { 156 | h.warn(`\`aspect-ratio-{ratio}\` is deprecated, use \`aspect-{ratio}\` instead`); 157 | } 158 | style = h.getCompleteStyle(`aspectRatio`, this.rest, { fractions: true }); 159 | if (style) return style; 160 | } 161 | 162 | if (this.consumePeeked(`tint-`)) { 163 | style = color(`tint`, this.rest, theme?.colors); 164 | if (style) return style; 165 | } 166 | 167 | if (this.consumePeeked(`bg-`)) { 168 | style = color(`bg`, this.rest, theme?.backgroundColor); 169 | if (style) return style; 170 | 171 | if (this.consumePeeked(`opacity-`)) { 172 | style = colorOpacity(`bg`, this.rest); 173 | if (style) return style; 174 | } 175 | } 176 | 177 | if (this.consumePeeked(`border`)) { 178 | style = border(this.rest, theme); 179 | if (style) return style; 180 | 181 | if (this.consumePeeked(`-opacity-`)) { 182 | style = colorOpacity(`border`, this.rest); 183 | if (style) return style; 184 | } 185 | } 186 | 187 | if (this.consumePeeked(`rounded`)) { 188 | style = borderRadius(this.rest, theme?.borderRadius); 189 | if (style) return style; 190 | } 191 | 192 | if (this.consumePeeked(`bottom-`)) { 193 | style = inset(`bottom`, this.rest, this.isNegative, theme?.inset); 194 | if (style) return style; 195 | } 196 | 197 | if (this.consumePeeked(`top-`)) { 198 | style = inset(`top`, this.rest, this.isNegative, theme?.inset); 199 | if (style) return style; 200 | } 201 | 202 | if (this.consumePeeked(`left-`)) { 203 | style = inset(`left`, this.rest, this.isNegative, theme?.inset); 204 | if (style) return style; 205 | } 206 | 207 | if (this.consumePeeked(`right-`)) { 208 | style = inset(`right`, this.rest, this.isNegative, theme?.inset); 209 | if (style) return style; 210 | } 211 | 212 | if (this.consumePeeked(`inset-`)) { 213 | style = inset(`inset`, this.rest, this.isNegative, theme?.inset); 214 | if (style) return style; 215 | } 216 | 217 | if (this.consumePeeked(`flex-`)) { 218 | if (this.consumePeeked(`basis`)) { 219 | style = flexBasis(this.rest, this.context, theme?.flexBasis); 220 | } else if (this.consumePeeked(`grow`)) { 221 | style = flexGrowShrink(`Grow`, this.rest, theme?.flexGrow); 222 | } else if (this.consumePeeked(`shrink`)) { 223 | style = flexGrowShrink(`Shrink`, this.rest, theme?.flexShrink); 224 | } else { 225 | style = flex(this.rest, theme?.flex); 226 | } 227 | if (style) return style; 228 | } 229 | 230 | if (this.consumePeeked(`basis`)) { 231 | style = flexBasis(this.rest, this.context, theme?.flexBasis); 232 | if (style) return style; 233 | } 234 | 235 | if (this.consumePeeked(`grow`)) { 236 | style = flexGrowShrink(`Grow`, this.rest, theme?.flexGrow); 237 | if (style) return style; 238 | } 239 | 240 | if (this.consumePeeked(`shrink`)) { 241 | style = flexGrowShrink(`Shrink`, this.rest, theme?.flexShrink); 242 | if (style) return style; 243 | } 244 | 245 | if (this.consumePeeked(`gap`)) { 246 | style = gap(this.rest, this.context, theme?.gap); 247 | if (style) return style; 248 | } 249 | 250 | if (this.consumePeeked(`shadow-color-opacity-`)) { 251 | style = colorOpacity(`shadow`, this.rest); 252 | if (style) return style; 253 | } 254 | 255 | if (this.consumePeeked(`shadow-opacity-`)) { 256 | style = shadowOpacity(this.rest); 257 | if (style) return style; 258 | } 259 | 260 | if (this.consumePeeked(`shadow-offset-`)) { 261 | style = shadowOffset(this.rest); 262 | if (style) return style; 263 | } 264 | 265 | if (this.consumePeeked(`shadow-radius-`)) { 266 | style = h.unconfiggedStyle(`shadowRadius`, this.rest); 267 | if (style) return style; 268 | } 269 | 270 | if (this.consumePeeked(`shadow-`)) { 271 | style = color(`shadow`, this.rest, theme?.colors); 272 | if (style) return style; 273 | } 274 | 275 | if (this.consumePeeked(`elevation-`)) { 276 | const elevation = parseInt(this.rest, 10); 277 | if (!Number.isNaN(elevation)) { 278 | return h.complete({ elevation }); 279 | } 280 | } 281 | 282 | if (this.consumePeeked(`opacity-`)) { 283 | style = opacity(this.rest, theme?.opacity); 284 | if (style) return style; 285 | } 286 | 287 | if (this.consumePeeked(`tracking-`)) { 288 | style = letterSpacing(this.rest, this.isNegative, theme?.letterSpacing); 289 | if (style) return style; 290 | } 291 | 292 | if (this.consumePeeked(`z-`)) { 293 | const zIndex = Number(theme?.zIndex?.[this.rest] ?? this.rest); 294 | if (!Number.isNaN(zIndex)) { 295 | return h.complete({ zIndex: this.isNegative ? -zIndex : zIndex }); 296 | } 297 | } 298 | 299 | if (this.consumePeeked(`size-`)) { 300 | style = size(this.rest, this.context, theme); 301 | if (style) return style; 302 | } 303 | 304 | if (this.consumePeeked(`scale-`)) { 305 | style = scale(this.rest, this.context, theme?.scale); 306 | if (style) return style; 307 | } 308 | 309 | if (this.consumePeeked(`rotate-`)) { 310 | style = rotate(this.rest, this.context, theme?.rotate); 311 | if (style) return style; 312 | } 313 | 314 | if (this.consumePeeked(`skew-`)) { 315 | style = skew(this.rest, this.context, theme?.skew); 316 | if (style) return style; 317 | } 318 | 319 | if (this.consumePeeked(`translate-`)) { 320 | style = translate(this.rest, this.context, theme?.translate); 321 | if (style) return style; 322 | } 323 | 324 | if (this.consumePeeked(`transform-none`)) { 325 | return transformNone(); 326 | } 327 | 328 | h.warn(`\`${this.rest}\` unknown or invalid utility`); 329 | return null; 330 | } 331 | 332 | private handlePossibleArbitraryBreakpointPrefix(prefix: string): boolean { 333 | // save the expense of running the regex with a quick sniff test 334 | if (prefix[0] !== `m`) return false; 335 | 336 | const match = prefix.match(/^(min|max)-(w|h)-\[([^\]]+)\]$/); 337 | if (!match) return false; 338 | 339 | if (!this.context.device?.windowDimensions) { 340 | this.isNull = true; 341 | return true; 342 | } 343 | 344 | const windowDims = this.context.device.windowDimensions; 345 | const [, type = ``, dir = ``, amount = ``] = match; 346 | const checkDimension = dir === `w` ? windowDims.width : windowDims.height; 347 | const parsedAmount = h.parseNumericValue(amount, this.context); 348 | if (parsedAmount === null) { 349 | this.isNull = true; 350 | return true; 351 | } 352 | 353 | const [bound, unit] = parsedAmount; 354 | if (unit !== `px`) { 355 | this.isNull = true; 356 | } 357 | 358 | if (type === `min` ? checkDimension >= bound : checkDimension <= bound) { 359 | this.incrementOrder(); 360 | } else { 361 | this.isNull = true; 362 | } 363 | return true; 364 | } 365 | 366 | private advance(amount = 1): void { 367 | this.position += amount; 368 | this.char = this.string[this.position]; 369 | } 370 | 371 | private get rest(): string { 372 | return this.peekSlice(0, this.string.length); 373 | } 374 | 375 | private peekSlice(begin: number, end: number): string { 376 | return this.string.slice(this.position + begin, this.position + end); 377 | } 378 | 379 | private consumePeeked(string: string): boolean { 380 | if (this.peekSlice(0, string.length) === string) { 381 | this.advance(string.length); 382 | return true; 383 | } 384 | return false; 385 | } 386 | 387 | private parsePrefixes( 388 | prefixes: string[], 389 | device: DeviceContext, 390 | platform: Platform, 391 | ): void { 392 | const widthBreakpoints = screens(this.config.theme?.screens); 393 | 394 | // loop through the prefixes ONE time, extracting useful info 395 | for (const prefix of prefixes) { 396 | if (widthBreakpoints[prefix]) { 397 | const breakpointOrder = widthBreakpoints[prefix]?.[2]; 398 | if (breakpointOrder !== undefined) { 399 | this.order = (this.order ?? 0) + breakpointOrder; 400 | } 401 | const windowWidth = device.windowDimensions?.width; 402 | if (windowWidth) { 403 | const [min, max] = widthBreakpoints[prefix] ?? [0, 0]; 404 | if (windowWidth < min || windowWidth >= max) { 405 | // breakpoint does not match 406 | this.isNull = true; 407 | } 408 | } else { 409 | this.isNull = true; 410 | } 411 | } else if (isPlatform(prefix)) { 412 | this.isNull = prefix !== platform; 413 | } else if (isOrientation(prefix)) { 414 | if (!device.windowDimensions) { 415 | this.isNull = true; 416 | } else { 417 | const deviceOrientation = 418 | device.windowDimensions.width > device.windowDimensions.height 419 | ? `landscape` 420 | : `portrait`; 421 | if (deviceOrientation !== prefix) { 422 | this.isNull = true; 423 | } else { 424 | this.incrementOrder(); 425 | } 426 | } 427 | } else if (prefix === `retina`) { 428 | if (device.pixelDensity === 2) { 429 | this.incrementOrder(); 430 | } else { 431 | this.isNull = true; 432 | } 433 | } else if (prefix === `dark`) { 434 | if (device.colorScheme !== `dark`) { 435 | this.isNull = true; 436 | } else { 437 | this.incrementOrder(); 438 | } 439 | } else if (!this.handlePossibleArbitraryBreakpointPrefix(prefix)) { 440 | this.isNull = true; 441 | } 442 | } 443 | } 444 | 445 | private parseIsNegative(): void { 446 | if (this.char === `-`) { 447 | this.advance(); 448 | this.isNegative = true; 449 | this.context.isNegative = true; 450 | } 451 | } 452 | 453 | private incrementOrder(): void { 454 | this.order = (this.order ?? 0) + 1; 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /src/__tests__/arbitrary-breakpoint-prefixes.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeEach, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`arbitrary breakpoint prefixes`, () => { 5 | let tw = create(); 6 | beforeEach(() => { 7 | tw = create(); 8 | }); 9 | 10 | const cases: Array< 11 | [ 12 | dims: { width: number; height: number } | null, 13 | utility: string, 14 | expected: Record, 15 | ] 16 | > = [ 17 | [{ width: 800, height: 600 }, `w-1 min-w-[900px]:w-2`, { width: 4 }], 18 | [{ width: 800, height: 600 }, `w-1 min-w-[700px]:w-2`, { width: 8 }], 19 | [{ width: 800, height: 600 }, `min-w-[700px]:w-2 w-1`, { width: 8 }], 20 | [{ width: 8, height: 6 }, `w-1 min-h-[7px]:w-2`, { width: 4 }], 21 | [{ width: 8, height: 6 }, `min-h-[7px]:w-2 w-1`, { width: 4 }], 22 | [{ width: 8, height: 6 }, `w-1 min-h-[5px]:max-h-[8px]:w-2`, { width: 8 }], 23 | [{ width: 4, height: 9 }, `w-1 min-h-[5px]:max-h-[8px]:w-2`, { width: 4 }], 24 | [null, `w-1 min-w-[900px]:w-2`, { width: 4 }], 25 | ]; 26 | 27 | test.each(cases)(`tw\`%s\` -> %s`, (dims, utility, expected) => { 28 | if (dims) { 29 | tw.setWindowDimensions(dims); 30 | } 31 | expect(tw.style(utility)).toEqual(expected); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/__tests__/aspect-ratio.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`aspect ratio`, () => { 5 | let tw = create(); 6 | beforeEach(() => (tw = create())); 7 | 8 | const cases: Array<[string, Record>]> = 9 | [ 10 | [`aspect-square`, { aspectRatio: 1 }], 11 | [`aspect-video`, { aspectRatio: 16 / 9 }], 12 | [`aspect-1`, { aspectRatio: 1 }], 13 | [`aspect-4`, { aspectRatio: 4 }], 14 | [`aspect-0.5`, { aspectRatio: 0.5 }], 15 | [`aspect-.5`, { aspectRatio: 0.5 }], 16 | [`aspect-16/9`, { aspectRatio: 16 / 9 }], 17 | // legacy, deprecated 18 | [`aspect-ratio-1`, { aspectRatio: 1 }], 19 | [`aspect-ratio-4`, { aspectRatio: 4 }], 20 | [`aspect-ratio-0.5`, { aspectRatio: 0.5 }], 21 | [`aspect-ratio-.5`, { aspectRatio: 0.5 }], 22 | [`aspect-ratio-16/9`, { aspectRatio: 16 / 9 }], 23 | ]; 24 | 25 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 26 | expect(tw.style(utility)).toEqual(expected); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/__tests__/borders.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`borders`, () => { 5 | let tw = create(); 6 | beforeEach(() => (tw = create())); 7 | 8 | const basicCases: Array<[string, Record]> = [ 9 | [`border-0`, { borderWidth: 0 }], 10 | [`border`, { borderWidth: 1 }], 11 | [`border-2`, { borderWidth: 2 }], 12 | [`border-4`, { borderWidth: 4 }], 13 | [`border-8`, { borderWidth: 8 }], 14 | [`border-t-2`, { borderTopWidth: 2 }], 15 | [`border-r-2`, { borderRightWidth: 2 }], 16 | [`border-b-2`, { borderBottomWidth: 2 }], 17 | [`border-l-2`, { borderLeftWidth: 2 }], 18 | [`border-white`, { borderColor: `#fff` }], 19 | [`border-t-white`, { borderTopColor: `#fff` }], 20 | [`border-t-[#e9c46a]`, { borderTopColor: `#e9c46a` }], 21 | [`border-t-[steelblue]`, { borderTopColor: `steelblue` }], 22 | [`border-blue-200`, { borderColor: `#bfdbfe` }], 23 | [`border-black border-opacity-50`, { borderColor: `rgba(0, 0, 0, 0.5)` }], 24 | [`border-dashed`, { borderStyle: `dashed` }], 25 | [`border-solid`, { borderStyle: `solid` }], 26 | [`border-dotted`, { borderStyle: `dotted` }], 27 | // Arbitrary Pixel Values 28 | [`border-[25px]`, { borderWidth: 25 }], 29 | [`border-t-[25px]`, { borderTopWidth: 25 }], 30 | [`border-r-[25px]`, { borderRightWidth: 25 }], 31 | [`border-b-[25px]`, { borderBottomWidth: 25 }], 32 | [`border-l-[25px]`, { borderLeftWidth: 25 }], 33 | // Arbitrary Rem Values 34 | [`border-[2.5rem]`, { borderWidth: 40 }], 35 | [`border-t-[2.5rem]`, { borderTopWidth: 40 }], 36 | [`border-r-[2.5rem]`, { borderRightWidth: 40 }], 37 | [`border-b-[2.5rem]`, { borderBottomWidth: 40 }], 38 | [`border-l-[2.5rem]`, { borderLeftWidth: 40 }], 39 | 40 | [`border-[7%]`, {}], // not supported in RN 41 | ]; 42 | 43 | test.each(basicCases)(`tw\`%s\` -> %s`, (utility, expected) => { 44 | expect(tw.style(utility)).toEqual(expected); 45 | }); 46 | }); 47 | 48 | describe(`border-radius`, () => { 49 | let tw = create(); 50 | beforeEach(() => (tw = create())); 51 | 52 | const cases: Array<[string, Record]> = [ 53 | [`rounded-none`, { borderRadius: 0 }], 54 | [`rounded-t-2xl`, { borderTopLeftRadius: 16, borderTopRightRadius: 16 }], 55 | [`rounded-b-2xl`, { borderBottomLeftRadius: 16, borderBottomRightRadius: 16 }], 56 | [`rounded-2xl`, { borderRadius: 16 }], 57 | [`rounded-l-lg`, { borderTopLeftRadius: 8, borderBottomLeftRadius: 8 }], 58 | [`rounded-l`, { borderTopLeftRadius: 4, borderBottomLeftRadius: 4 }], 59 | [`rounded-r`, { borderTopRightRadius: 4, borderBottomRightRadius: 4 }], 60 | [`rounded-tl-lg`, { borderTopLeftRadius: 8 }], 61 | 62 | // arbitrary 63 | [`rounded-[30px]`, { borderRadius: 30 }], 64 | [`rounded-[7rem]`, { borderRadius: 7 * 16 }], 65 | [`rounded-[30%]`, {}], // not supported in RN 66 | ]; 67 | 68 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 69 | expect(tw.style(utility)).toEqual(expected); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/__tests__/color-scheme.spec.tsx: -------------------------------------------------------------------------------- 1 | import TestRenderer from 'react-test-renderer'; 2 | import rn from 'react-native'; 3 | import { describe, it, expect } from '@jest/globals'; 4 | import React from 'react'; 5 | import type { TailwindFn } from '../'; 6 | import { create, useDeviceContext, useAppColorScheme } from '../'; 7 | 8 | jest.mock(`react-native`, () => ({ 9 | Platform: { OS: `ios` }, 10 | useColorScheme: () => `light`, 11 | useWindowDimensions: () => ({ width: 320, height: 640, fontScale: 1, scale: 2 }), 12 | })); 13 | 14 | const Test: React.FC<{ tw: TailwindFn; initial: 'light' | 'dark' | 'device' }> = ({ 15 | tw, 16 | initial, 17 | }) => { 18 | useDeviceContext(tw, { 19 | observeDeviceColorSchemeChanges: false, 20 | initialColorScheme: initial, 21 | }); 22 | const [colorScheme] = useAppColorScheme(tw); 23 | return ( 24 | <> 25 | {String(colorScheme)} 26 | {tw.prefixMatch(`dark`) ? `match:dark` : `no-match:dark`} 27 | 28 | ); 29 | }; 30 | 31 | describe(`useAppColorScheme()`, () => { 32 | it(`should initialize to ambient color scheme, if no initializer`, () => { 33 | rn.useColorScheme = () => `dark`; 34 | 35 | let component = TestRenderer.create(); 36 | expect(component.toJSON()).toEqual([`dark`, `match:dark`]); 37 | 38 | rn.useColorScheme = () => `light`; 39 | component = TestRenderer.create(); 40 | expect(component.toJSON()).toEqual([`light`, `no-match:dark`]); 41 | 42 | rn.useColorScheme = () => null; 43 | component = TestRenderer.create(); 44 | expect(component.toJSON()).toEqual([`null`, `no-match:dark`]); 45 | 46 | rn.useColorScheme = () => undefined; 47 | component = TestRenderer.create(); 48 | expect(component.toJSON()).toEqual([`undefined`, `no-match:dark`]); 49 | }); 50 | 51 | it(`should initialize to explicitly passed color scheme when initializer provided`, () => { 52 | rn.useColorScheme = () => `dark`; 53 | 54 | let component = TestRenderer.create(); 55 | expect(component.toJSON()).toEqual([`light`, `no-match:dark`]); 56 | 57 | rn.useColorScheme = () => `light`; 58 | component = TestRenderer.create(); 59 | expect(component.toJSON()).toEqual([`dark`, `match:dark`]); 60 | }); 61 | 62 | test(`nested components should read same app color scheme`, () => { 63 | const tw = create(); 64 | 65 | const NestedComponent: React.FC = () => { 66 | const [colorScheme] = useAppColorScheme(tw); 67 | return ( 68 | <> 69 | {tw.prefixMatch(`dark`) ? `nested:match:dark` : `nested:no-match:dark`} 70 | {`nested:${colorScheme}`} 71 | 72 | ); 73 | }; 74 | 75 | const Toggler: React.FC<{ onPress: () => unknown }> = () => null; 76 | 77 | const Component: React.FC<{ initial: 'light' | 'dark' | 'device' }> = ({ 78 | initial, 79 | }) => { 80 | useDeviceContext(tw, { 81 | observeDeviceColorSchemeChanges: false, 82 | initialColorScheme: initial, 83 | }); 84 | const [colorScheme, toggleColorScheme] = useAppColorScheme(tw); 85 | return ( 86 | <> 87 | toggleColorScheme()} /> 88 | {tw.prefixMatch(`dark`) ? `outer:match:dark` : `outer:no-match:dark`} 89 | {`outer:${colorScheme}`} 90 | 91 | 92 | ); 93 | }; 94 | 95 | const renderer = TestRenderer.create(); 96 | expect(assertArray(renderer.toJSON())).toEqual([ 97 | `outer:no-match:dark`, 98 | `outer:light`, 99 | `nested:no-match:dark`, 100 | `nested:light`, 101 | ]); 102 | TestRenderer.act(() => { 103 | renderer.root.findByType(Toggler).props.onPress(); 104 | }); 105 | expect(assertArray(renderer.toJSON())).toEqual([ 106 | `outer:match:dark`, 107 | `outer:dark`, 108 | `nested:match:dark`, 109 | `nested:dark`, 110 | ]); 111 | }); 112 | }); 113 | 114 | function assertArray(value: T | T[]): T[] { 115 | if (!Array.isArray(value)) throw new Error(`expected array, got ${value}`); 116 | return value; 117 | } 118 | -------------------------------------------------------------------------------- /src/__tests__/colors.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | import { color } from '../resolve/color'; 4 | 5 | describe(`colors`, () => { 6 | let tw = create(); 7 | beforeEach(() => (tw = create())); 8 | 9 | test(`background color without opacity`, () => { 10 | expect(tw`bg-black`).toEqual({ backgroundColor: `#000` }); 11 | expect(tw`bg-white`).toEqual({ backgroundColor: `#fff` }); 12 | expect(tw`bg-blue-400`).toEqual({ backgroundColor: `#60a5fa` }); 13 | }); 14 | 15 | test(`color with opacity`, () => { 16 | expect(tw`bg-black bg-opacity-50`).toEqual({ 17 | backgroundColor: `rgba(0, 0, 0, 0.5)`, 18 | }); 19 | expect(tw`text-black text-opacity-50`).toEqual({ 20 | color: `rgba(0, 0, 0, 0.5)`, 21 | }); 22 | expect(tw`text-black text-opacity-100`).toEqual({ 23 | color: `rgba(0, 0, 0, 1)`, 24 | }); 25 | }); 26 | 27 | test(`color with opacity doesn't affect other utilities`, () => { 28 | expect(tw`bg-white bg-opacity-50`).toEqual({ 29 | backgroundColor: `rgba(255, 255, 255, 0.5)`, 30 | }); 31 | expect(tw`bg-white`).toEqual({ backgroundColor: `#fff` }); 32 | }); 33 | 34 | test(`color with opacity shorthand`, () => { 35 | expect(tw`bg-black/50`).toEqual({ backgroundColor: `rgba(0, 0, 0, 0.5)` }); 36 | expect(tw`text-red-300/75`).toEqual({ color: `rgba(252, 165, 165, 0.75)` }); 37 | // shorthand preferred 38 | expect(tw`bg-black/50 bg-opacity-75`).toEqual({ 39 | backgroundColor: `rgba(0, 0, 0, 0.5)`, 40 | }); 41 | }); 42 | 43 | test(`bg colors with customized configs`, () => { 44 | // customize `theme.backgroundColors` 45 | tw = create({ 46 | theme: { backgroundColor: { foo: `#ff0000`, bar: `#00f` } }, 47 | }); 48 | expect(tw`bg-foo`).toEqual({ backgroundColor: `#ff0000` }); 49 | expect(tw`bg-bar`).toEqual({ backgroundColor: `#00f` }); 50 | 51 | // should work the same if using `theme.colors` to customize 52 | tw = create({ theme: { colors: { foo: `#ff0000`, bar: `#00f` } } }); 53 | expect(tw`bg-foo`).toEqual({ backgroundColor: `#ff0000` }); 54 | expect(tw`bg-bar`).toEqual({ backgroundColor: `#00f` }); 55 | }); 56 | 57 | test(`text colors`, () => { 58 | expect(tw`text-black`).toEqual({ color: `#000` }); 59 | }); 60 | 61 | test(`tint colors`, () => { 62 | expect(tw`tint-black`).toEqual({ tintColor: `#000` }); 63 | expect(tw`tint-[#eaeaea]`).toEqual({ tintColor: `#eaeaea` }); 64 | expect(tw`tint-black/50`).toEqual({ tintColor: `rgba(0, 0, 0, 0.5)` }); 65 | tw = create({ theme: { colors: { foo: `#ff0000`, bar: `#00f` } } }); 66 | expect(tw`tint-foo`).toEqual({ tintColor: `#ff0000` }); 67 | expect(tw`tint-bar`).toEqual({ tintColor: `#00f` }); 68 | }); 69 | 70 | test(`rgb/a configged colors`, () => { 71 | tw = create({ 72 | theme: { 73 | colors: { 74 | foo: `rgb(1, 2, 3)`, 75 | bar: `rgba(4, 5, 6, 0.5)`, 76 | fizz: `rgb(7 8 9)`, 77 | buzz: `rgba(10 11 12 / 0.6)`, 78 | }, 79 | }, 80 | }); 81 | 82 | expect(tw`text-foo bg-bar`).toEqual({ 83 | color: `rgb(1, 2, 3)`, 84 | backgroundColor: `rgba(4, 5, 6, 0.5)`, 85 | }); 86 | 87 | expect(tw`text-foo text-opacity-75`).toEqual({ 88 | color: `rgba(1, 2, 3, 0.75)`, 89 | }); 90 | 91 | expect(tw`text-foo/50 bg-bar/75`).toEqual({ 92 | color: `rgba(1, 2, 3, 0.5)`, 93 | backgroundColor: `rgba(4, 5, 6, 0.75)`, 94 | }); 95 | 96 | expect(tw`text-fizz bg-buzz`).toEqual({ 97 | color: `rgb(7 8 9)`, 98 | backgroundColor: `rgba(10 11 12 / 0.6)`, 99 | }); 100 | 101 | expect(tw`text-fizz text-opacity-75`).toEqual({ 102 | color: `rgba(7 8 9 / 0.75)`, 103 | }); 104 | 105 | expect(tw`text-fizz/50 bg-buzz/75`).toEqual({ 106 | color: `rgba(7 8 9 / 0.5)`, 107 | backgroundColor: `rgba(10 11 12 / 0.75)`, 108 | }); 109 | }); 110 | 111 | test(`hsl/a configged colors`, () => { 112 | tw = create({ 113 | theme: { 114 | colors: { 115 | foo: `hsl(1, 2%, 3%)`, 116 | bar: `hsla(4, 5%, 6%, 0.5)`, 117 | fizz: `hsl(7 8% 9%)`, 118 | buzz: `hsla(10 11% 12% / 0.6)`, 119 | }, 120 | }, 121 | }); 122 | 123 | expect(tw`text-foo bg-bar`).toEqual({ 124 | color: `hsl(1, 2%, 3%)`, 125 | backgroundColor: `hsla(4, 5%, 6%, 0.5)`, 126 | }); 127 | 128 | expect(tw`text-foo text-opacity-75`).toEqual({ 129 | color: `hsla(1, 2%, 3%, 0.75)`, 130 | }); 131 | 132 | expect(tw`text-foo/50 bg-bar/75`).toEqual({ 133 | color: `hsla(1, 2%, 3%, 0.5)`, 134 | backgroundColor: `hsla(4, 5%, 6%, 0.75)`, 135 | }); 136 | 137 | expect(tw`text-fizz bg-buzz`).toEqual({ 138 | color: `hsl(7 8% 9%)`, 139 | backgroundColor: `hsla(10 11% 12% / 0.6)`, 140 | }); 141 | 142 | expect(tw`text-fizz text-opacity-75`).toEqual({ 143 | color: `hsla(7 8% 9% / 0.75)`, 144 | }); 145 | 146 | expect(tw`text-fizz/50 bg-buzz/75`).toEqual({ 147 | color: `hsla(7 8% 9% / 0.5)`, 148 | backgroundColor: `hsla(10 11% 12% / 0.75)`, 149 | }); 150 | }); 151 | 152 | test(`DEFAULT special modifier`, () => { 153 | tw = create({ 154 | theme: { colors: { foo: { '100': `#ff0`, DEFAULT: `#EEF` } } }, 155 | }); 156 | expect(tw`text-foo-100 bg-foo`).toEqual({ 157 | color: `#ff0`, 158 | backgroundColor: `#EEF`, 159 | }); 160 | }); 161 | 162 | test(`arbitrary color values`, () => { 163 | expect(tw`bg-[#012]`).toEqual({ backgroundColor: `#012` }); 164 | expect(tw`bg-[rebeccapurple]`).toEqual({ backgroundColor: `rebeccapurple` }); 165 | expect(tw`bg-[rgba(3,4,5,0.1)]`).toEqual({ 166 | backgroundColor: `rgba(3,4,5,0.1)`, 167 | }); 168 | expect(tw`bg-[hsla(6,7%,8%,0.2)]`).toEqual({ 169 | backgroundColor: `hsla(6,7%,8%,0.2)`, 170 | }); 171 | 172 | expect(tw`bg-[#012] bg-opacity-50`).toEqual({ 173 | backgroundColor: `rgba(0, 17, 34, 0.5)`, 174 | }); 175 | expect(tw`bg-[rgba(3,4,5,0.1)] bg-opacity-50`).toEqual({ 176 | backgroundColor: `rgba(3,4,5, 0.5)`, 177 | }); 178 | expect(tw`bg-[hsla(6,7%,8%,0.2)] bg-opacity-50`).toEqual({ 179 | backgroundColor: `hsla(6,7%,8%, 0.5)`, 180 | }); 181 | }); 182 | 183 | test(`non-group dashed custom colors`, () => { 184 | tw = create({ 185 | theme: { colors: { 'indigo-lighter': `#b3bcf5`, indigo: `#5c6ac4` } }, 186 | }); 187 | expect(tw`text-indigo bg-indigo-lighter`).toEqual({ 188 | color: `#5c6ac4`, 189 | backgroundColor: `#b3bcf5`, 190 | }); 191 | }); 192 | 193 | test(`transparent`, () => { 194 | expect(tw`text-transparent`).toEqual({ color: `transparent` }); 195 | }); 196 | 197 | test(`non-color arbitrary value not returned`, () => { 198 | expect(color(`text`, `[50vh]`, {})).toBeNull(); 199 | }); 200 | 201 | test(`object syntax with deeply nested colors`, () => { 202 | tw = create({ 203 | theme: { 204 | colors: { 205 | foo: { 206 | DEFAULT: `#ff0000`, 207 | bar: { DEFAULT: `#00f`, baz: `#EEF` }, 208 | }, 209 | }, 210 | }, 211 | }); 212 | expect(tw`bg-foo text-foo-bar`).toEqual({ 213 | backgroundColor: `#ff0000`, 214 | color: `#00f`, 215 | }); 216 | expect(tw`bg-foo-bar-baz`).toEqual({ backgroundColor: `#EEF` }); 217 | expect(tw`bg-foo-baz`).toEqual({}); 218 | }); 219 | 220 | test(`object syntax with color names containing dashes`, () => { 221 | tw = create({ 222 | theme: { 223 | colors: { 224 | foo: { 225 | DEFAULT: `#ff0000`, 226 | bar: `#00f`, 227 | 'bar-baz': `#EEF`, 228 | }, 229 | }, 230 | }, 231 | }); 232 | expect(tw`bg-foo text-foo-bar`).toEqual({ 233 | backgroundColor: `#ff0000`, 234 | color: `#00f`, 235 | }); 236 | expect(tw`bg-foo-bar-baz`).toEqual({ backgroundColor: `#EEF` }); 237 | expect(tw`bg-foo-baz`).toEqual({}); 238 | 239 | tw = create({ 240 | theme: { 241 | colors: { 242 | 'jim-jam': { 243 | DEFAULT: `#00ff00`, 244 | slam: `#eea`, 245 | }, 246 | }, 247 | }, 248 | }); 249 | expect(tw`text-jim`).toEqual({}); 250 | expect(tw`text-jim-jam`).toEqual({ color: `#00ff00` }); 251 | expect(tw`text-jim-jam-slam`).toEqual({ color: `#eea` }); 252 | expect(tw`text-jim-jam-slam-nope`).toEqual({}); 253 | }); 254 | }); 255 | -------------------------------------------------------------------------------- /src/__tests__/custom-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | // @ts-ignore 3 | import tailwindPlugin from 'tailwindcss/plugin'; 4 | import type { TwConfig } from '../tw-config'; 5 | import { create, plugin } from '../'; 6 | 7 | describe(`custom registered utilities`, () => { 8 | test(`register custom utilities, using package plugin fn`, () => { 9 | const config: TwConfig = { 10 | plugins: [ 11 | plugin(({ addUtilities }) => { 12 | addUtilities({ 13 | btn: { paddingTop: 33 }, 14 | custom: `mt-1 text-white`, 15 | }); 16 | }), 17 | ], 18 | }; 19 | const tw = create(config); 20 | expect(tw`btn`).toEqual({ paddingTop: 33 }); 21 | expect(tw`custom`).toEqual({ marginTop: 4, color: `#fff` }); 22 | // works with media queries 23 | expect(tw`md:btn`).toEqual({}); 24 | tw.setWindowDimensions({ width: 800, height: 800 }); 25 | expect(tw`md:btn`).toEqual({ paddingTop: 33 }); 26 | expect(tw`md:custom`).toEqual({ marginTop: 4, color: `#fff` }); 27 | }); 28 | 29 | test(`supports leading dot for added utilities`, () => { 30 | const config: TwConfig = { 31 | plugins: [ 32 | plugin(({ addUtilities }) => { 33 | addUtilities({ 34 | '.btn': { textTransform: `uppercase` }, 35 | '.resize-repeat': { resizeMode: `repeat` }, 36 | }); 37 | }), 38 | ], 39 | }; 40 | const tw = create(config); 41 | expect(tw`resize-repeat`).toMatchObject({ resizeMode: `repeat` }); 42 | expect(tw`btn`).toMatchObject({ textTransform: `uppercase` }); 43 | }); 44 | 45 | test(`registered custom utilities merge with regular utilities`, () => { 46 | const config: TwConfig = { 47 | plugins: [ 48 | plugin(({ addUtilities }) => { 49 | addUtilities({ 50 | custom: `mt-1 text-white`, 51 | }); 52 | }), 53 | ], 54 | }; 55 | const tw = create(config); 56 | expect(tw`custom mr-1`).toEqual({ marginTop: 4, color: `#fff`, marginRight: 4 }); 57 | }); 58 | 59 | test(`register custom utilities, using tailwindcss fn`, () => { 60 | const config: TwConfig = { 61 | plugins: [ 62 | // @ts-ignore 63 | tailwindPlugin(({ addUtilities }) => { 64 | addUtilities({ 65 | // @ts-ignore 66 | btn: { paddingTop: 33 }, 67 | custom: `mt-1 text-white`, 68 | }); 69 | }), 70 | ], 71 | }; 72 | const tw = create(config); 73 | expect(tw`btn`).toEqual({ paddingTop: 33 }); 74 | expect(tw`custom`).toEqual({ marginTop: 4, color: `#fff` }); 75 | expect(tw`custom`).toEqual({ marginTop: 4, color: `#fff` }); 76 | }); 77 | 78 | test(`custom utils override built-in classes`, () => { 79 | const config: TwConfig = { 80 | plugins: [ 81 | plugin(({ addUtilities }) => { 82 | addUtilities({ 83 | 'items-center': { paddingTop: 33 }, 84 | }); 85 | }), 86 | ], 87 | }; 88 | const tw = create(config); 89 | expect(tw`items-center`).toEqual({ paddingTop: 33 }); 90 | }); 91 | 92 | test(`attempt to use anything but addUtilities throws`, () => { 93 | const config: TwConfig = { 94 | plugins: [ 95 | plugin(({ addBase }) => { 96 | addBase(); 97 | }), 98 | ], 99 | }; 100 | expect(() => create(config)).toThrow(); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/__tests__/dark-mode.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`dark mode`, () => { 5 | let tw = create(); 6 | beforeEach(() => (tw = create())); 7 | 8 | test(`dark mode`, () => { 9 | expect(tw`mt-1 dark:mt-2`).toEqual({ marginTop: 4 }); 10 | tw.setColorScheme(`dark`); 11 | expect(tw`mt-1 dark:mt-2`).toEqual({ marginTop: 8 }); 12 | tw.setColorScheme(`light`); 13 | expect(tw`mt-1 dark:mt-2`).toEqual({ marginTop: 4 }); 14 | tw.setColorScheme(undefined); 15 | expect(tw`mt-1 dark:mt-2`).toEqual({ marginTop: 4 }); 16 | tw.setColorScheme(`dark`); 17 | // out of order 18 | expect(tw`dark:mt-2 mt-1`).toEqual({ marginTop: 8 }); 19 | }); 20 | 21 | test(`mixing color opacity with dark mode`, () => { 22 | expect(tw`bg-gray-100 dark:bg-gray-800 bg-opacity-50`).toEqual({ 23 | backgroundColor: `rgba(243, 244, 246, 0.5)`, 24 | }); 25 | 26 | tw.setColorScheme(`dark`); 27 | 28 | expect(tw`bg-gray-100 dark:bg-gray-800 bg-opacity-50`).toEqual({ 29 | backgroundColor: `rgba(31, 41, 55, 0.5)`, 30 | }); 31 | }); 32 | 33 | test(`dark mode opacity shorthands`, () => { 34 | expect(tw`bg-gray-100/50 dark:bg-gray-800/50`).toEqual({ 35 | backgroundColor: `rgba(243, 244, 246, 0.5)`, 36 | }); 37 | 38 | expect(tw`bg-white dark:bg-white/50`).toEqual({ 39 | backgroundColor: `#fff`, 40 | }); 41 | 42 | // ignores dark:bg-opacity-25 when merging, not dark mode 43 | expect(tw`bg-white dark:bg-white/50 dark:bg-opacity-25`).toEqual({ 44 | backgroundColor: `#fff`, 45 | }); 46 | 47 | // merges bg-opacity-50 48 | expect(tw`bg-white dark:bg-white/75 bg-opacity-50`).toEqual({ 49 | backgroundColor: `rgba(255, 255, 255, 0.5)`, 50 | }); 51 | 52 | tw.setColorScheme(`dark`); 53 | 54 | expect(tw`bg-gray-100/50 dark:bg-gray-800/50`).toEqual({ 55 | backgroundColor: `rgba(31, 41, 55, 0.5)`, 56 | }); 57 | 58 | expect(tw`bg-white dark:bg-white/50`).toEqual({ 59 | backgroundColor: `rgba(255, 255, 255, 0.5)`, 60 | }); 61 | 62 | // shorthand opacity wins over "merged" bg-opacity-X 63 | expect(tw`bg-white dark:bg-white/50 bg-opacity-75 dark:bg-opacity-25`).toEqual({ 64 | backgroundColor: `rgba(255, 255, 255, 0.5)`, 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/__tests__/flex.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | import type { TwTheme } from '../tw-config'; 3 | import { create } from '..'; 4 | 5 | describe(`flex grow/shrink`, () => { 6 | let tw = create(); 7 | beforeEach(() => (tw = create())); 8 | 9 | const cases: Array<[string, Record]> = [ 10 | [`flex-shrink-0`, { flexShrink: 0 }], 11 | [`flex-shrink`, { flexShrink: 1 }], 12 | [`flex-grow-0`, { flexGrow: 0 }], 13 | [`flex-grow`, { flexGrow: 1 }], 14 | [`flex-grow-77`, { flexGrow: 77 }], 15 | [`grow`, { flexGrow: 1 }], 16 | [`grow-0`, { flexGrow: 0 }], 17 | [`grow-33`, { flexGrow: 33 }], 18 | [`grow-[33]`, { flexGrow: 33 }], 19 | [`shrink`, { flexShrink: 1 }], 20 | [`shrink-0`, { flexShrink: 0 }], 21 | [`shrink-77`, { flexShrink: 77 }], 22 | 23 | [`basis-0`, { flexBasis: 0 }], 24 | [`basis-1`, { flexBasis: 4 }], 25 | [`basis-1/2`, { flexBasis: `50%` }], 26 | [`basis-3.5`, { flexBasis: 14 }], 27 | [`basis-auto`, { flexBasis: `auto` }], 28 | [`basis-full`, { flexBasis: `100%` }], 29 | [`flex-basis-0`, { flexBasis: 0 }], 30 | [`flex-basis-1`, { flexBasis: 4 }], 31 | [`flex-basis-1/2`, { flexBasis: `50%` }], 32 | [`flex-basis-3.5`, { flexBasis: 14 }], 33 | [`flex-basis-auto`, { flexBasis: `auto` }], 34 | [`flex-basis-full`, { flexBasis: `100%` }], 35 | ]; 36 | 37 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 38 | expect(tw.style(utility)).toEqual(expected); 39 | }); 40 | }); 41 | 42 | describe(`flex shorthand utilities`, () => { 43 | const cases: Array<[string, Record, TwTheme['flex'] | null]> = 44 | [ 45 | [`flex-auto`, { flexGrow: 1, flexShrink: 1, flexBasis: `auto` }, null], 46 | [`flex-initial`, { flexGrow: 0, flexShrink: 1, flexBasis: `auto` }, null], 47 | [`flex-none`, { flexGrow: 0, flexShrink: 0, flexBasis: `auto` }, null], 48 | [`flex-1`, { flexGrow: 1, flexShrink: 1, flexBasis: `0%` }, null], 49 | 50 | // unsupported 51 | [`flex-revert`, {}, null], 52 | [`flex-unset`, {}, null], 53 | [`flex-min-content`, {}, null], 54 | 55 | // arbitrary 56 | [`flex-33`, { flexGrow: 33, flexBasis: `0%` }, null], 57 | [`flex-0.2`, { flexGrow: 0.2, flexBasis: `0%` }, null], 58 | 59 | // configged with two numeric values 60 | [`flex-custom`, { flexGrow: 11, flexShrink: 22 }, { custom: `11 22` }], 61 | 62 | // configged with number/width combo 63 | [`flex-custom2`, { flexGrow: 3, flexBasis: 10 }, { custom2: `3 10px` }], 64 | 65 | // configged with 3 values 66 | [ 67 | `flex-custom3`, 68 | { flexGrow: 5, flexShrink: 6, flexBasis: `10%` }, 69 | { custom3: `5 6 10%` }, 70 | ], 71 | ]; 72 | 73 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected, config) => { 74 | const tw = create(config ? { theme: { flex: config } } : {}); 75 | expect(tw.style(utility)).toEqual(expected); 76 | }); 77 | }); 78 | 79 | describe(`flex gap`, () => { 80 | let tw = create(); 81 | beforeEach(() => (tw = create())); 82 | 83 | const cases: Array<[string, Record]> = [ 84 | [`gap-0`, { gap: 0 }], 85 | [`gap-1`, { gap: 4 }], 86 | [`gap-1.5`, { gap: 6 }], 87 | [`gap-y-0`, { rowGap: 0 }], 88 | [`gap-y-1`, { rowGap: 4 }], 89 | [`gap-y-1.5`, { rowGap: 6 }], 90 | [`gap-x-0`, { columnGap: 0 }], 91 | [`gap-x-1`, { columnGap: 4 }], 92 | [`gap-x-1.5`, { columnGap: 6 }], 93 | [`gap-px`, { gap: 1 }], 94 | [`gap-1px`, { gap: 1 }], 95 | [`gap-[1px]`, { gap: 1 }], 96 | [`gap-[10px]`, { gap: 10 }], 97 | ]; 98 | 99 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 100 | expect(tw.style(utility)).toEqual(expected); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /src/__tests__/font-size.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import type { TwConfig } from '../tw-config'; 3 | import { create } from '..'; 4 | 5 | describe(`font size`, () => { 6 | let tw = create(); 7 | beforeEach(() => (tw = create())); 8 | 9 | test(`font-sizes`, () => { 10 | expect(tw`text-xs`).toMatchObject({ fontSize: 12, lineHeight: 16 }); 11 | expect(tw`text-sm`).toMatchObject({ fontSize: 14, lineHeight: 20 }); 12 | expect(tw`text-base`).toMatchObject({ fontSize: 16, lineHeight: 24 }); 13 | expect(tw`text-lg`).toMatchObject({ fontSize: 18, lineHeight: 28 }); 14 | expect(tw`text-2xl`).toMatchObject({ fontSize: 24, lineHeight: 32 }); 15 | expect(tw`text-3xl`).toMatchObject({ fontSize: 30, lineHeight: 36 }); 16 | expect(tw`text-4xl`).toMatchObject({ fontSize: 36, lineHeight: 40 }); 17 | expect(tw`text-5xl`).toMatchObject({ fontSize: 48, lineHeight: 48 }); 18 | expect(tw`text-6xl`).toMatchObject({ fontSize: 60, lineHeight: 60 }); 19 | expect(tw`text-7xl`).toMatchObject({ fontSize: 72, lineHeight: 72 }); 20 | expect(tw`text-8xl`).toMatchObject({ fontSize: 96, lineHeight: 96 }); 21 | expect(tw`text-9xl`).toMatchObject({ fontSize: 128, lineHeight: 128 }); 22 | }); 23 | 24 | test(`arbitrary font sizes`, () => { 25 | expect(tw`text-[11px]`).toMatchObject({ fontSize: 11 }); 26 | tw.setWindowDimensions({ width: 800, height: 600 }); 27 | expect(tw`text-[50vw]`).toMatchObject({ fontSize: 400 }); 28 | expect(tw`text-[50vh]`).toMatchObject({ fontSize: 300 }); 29 | }); 30 | 31 | test(`line-height shorthand`, () => { 32 | expect(tw`text-sm leading-6`).toMatchObject({ fontSize: 14, lineHeight: 24 }); 33 | expect(tw`text-sm/6`).toMatchObject({ fontSize: 14, lineHeight: 24 }); 34 | }); 35 | 36 | test(`font-sizes with relative line-height`, () => { 37 | const config: TwConfig = { 38 | theme: { 39 | fontSize: { 40 | relative: [`1.25rem`, { lineHeight: `1.5` }], 41 | relativeem: [`1.25rem`, { lineHeight: `1.5em` }], 42 | twostrings: [`1.25rem`, `1.5`], 43 | twostringsem: [`1.25rem`, `1.5em`], 44 | }, 45 | }, 46 | }; 47 | tw = create(config); 48 | expect(tw`text-relative`).toMatchObject({ fontSize: 20, lineHeight: 30 }); 49 | expect(tw`text-relativeem`).toMatchObject({ fontSize: 20, lineHeight: 30 }); 50 | expect(tw`text-twostrings`).toMatchObject({ fontSize: 20, lineHeight: 30 }); 51 | expect(tw`text-twostringsem`).toMatchObject({ fontSize: 20, lineHeight: 30 }); 52 | }); 53 | 54 | test(`customized font-size variations`, () => { 55 | tw = create({ theme: { fontSize: { xs: `0.75rem` } } }); 56 | 57 | expect(tw`text-xs`).toEqual({ fontSize: 12 }); 58 | 59 | tw = create({ theme: { fontSize: { xs: [`0.75rem`, `0.75rem`] } } }); 60 | expect(tw`text-xs`).toEqual({ fontSize: 12, lineHeight: 12 }); 61 | 62 | tw = create({ theme: { fontSize: { xs: [`0.75rem`, { lineHeight: `0.75rem` }] } } }); 63 | expect(tw`text-xs`).toEqual({ fontSize: 12, lineHeight: 12 }); 64 | 65 | tw = create({ theme: { fontSize: { xs: [`0.75rem`, { letterSpacing: `1px` }] } } }); 66 | expect(tw`text-xs`).toEqual({ fontSize: 12, letterSpacing: 1 }); 67 | 68 | tw = create({ theme: { fontSize: { xs: [`0.75rem`, { fontWeight: `700` }] } } }); 69 | expect(tw`text-xs`).toEqual({ fontSize: 12, fontWeight: 700 }); 70 | 71 | tw = create({ 72 | theme: { 73 | fontSize: { xs: [`0.75rem`, { lineHeight: `0.5rem`, letterSpacing: `1px` }] }, 74 | }, 75 | }); 76 | expect(tw`text-xs`).toEqual({ fontSize: 12, letterSpacing: 1, lineHeight: 8 }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/__tests__/inset.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`inset`, () => { 5 | let tw = create(); 6 | beforeEach(() => (tw = create())); 7 | 8 | const cases: Array<[string, Record]> = [ 9 | [`bottom-0`, { bottom: 0 }], 10 | [`top-0`, { top: 0 }], 11 | [`left-0`, { left: 0 }], 12 | [`right-0`, { right: 0 }], 13 | [`inset-0`, { top: 0, bottom: 0, left: 0, right: 0 }], 14 | [`bottom-1/3`, { bottom: `33.333333%` }], 15 | [`top-2`, { top: 8 }], 16 | [`-top-2`, { top: -8 }], 17 | [`inset-1`, { top: 4, bottom: 4, left: 4, right: 4 }], 18 | [`inset-y-1`, { top: 4, bottom: 4 }], 19 | [`inset-x-1`, { left: 4, right: 4 }], 20 | [`right-[333px]`, { right: 333 }], 21 | [`right-[-16px]`, { right: -16 }], 22 | [`-right-[16px]`, { right: -16 }], 23 | [`left-17/18`, { left: `${(17 / 18) * 100}%` }], 24 | [`top-px`, { top: 1 }], 25 | // arbitrary, not configged number 26 | [`top-15`, { top: 60 }], 27 | // auto values 28 | [`top-auto`, { top: `auto` }], 29 | ]; 30 | 31 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 32 | expect(tw.style(utility)).toEqual(expected); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/__tests__/letter-spacing.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`letter-spacing (tracking-X)`, () => { 5 | let tw = create(); 6 | beforeEach(() => (tw = create())); 7 | 8 | it(`handles relative letter-spacing`, () => { 9 | expect(tw`text-xs tracking-tighter`).toMatchObject({ letterSpacing: -0.6 }); 10 | expect(tw`text-base tracking-tighter`).toMatchObject({ letterSpacing: -0.8 }); 11 | expect(tw`text-base tracking-tight`).toMatchObject({ letterSpacing: -0.4 }); 12 | expect(tw`text-base tracking-normal`).toMatchObject({ letterSpacing: 0 }); 13 | expect(tw`text-base tracking-wide`).toMatchObject({ letterSpacing: 0.4 }); 14 | expect(tw`text-base tracking-wider`).toMatchObject({ letterSpacing: 0.8 }); 15 | expect(tw`text-base tracking-widest`).toMatchObject({ letterSpacing: 1.6 }); 16 | }); 17 | 18 | test(`non-em configged letter-spacing`, () => { 19 | tw = create({ theme: { letterSpacing: { custom1: `0.125rem`, custom2: `3px` } } }); 20 | expect(tw`tracking-custom1`).toMatchObject({ letterSpacing: 2 }); 21 | expect(tw`tracking-custom2`).toMatchObject({ letterSpacing: 3 }); 22 | }); 23 | 24 | test(`letter-spacing with no font-size has no effect`, () => { 25 | expect(tw`tracking-wide`).toEqual({}); 26 | }); 27 | 28 | test(`letter-spacing not dependent on className order`, () => { 29 | expect(tw`tracking-wide text-base`).toMatchObject({ letterSpacing: 0.4 }); 30 | }); 31 | 32 | const arbitrary: Array<[string, Record]> = [ 33 | [`tracking-[3px]`, { letterSpacing: 3 }], 34 | [`tracking-[-3px]`, { letterSpacing: -3 }], 35 | [`-tracking-[3px]`, { letterSpacing: -3 }], 36 | ]; 37 | 38 | test.each(arbitrary)(`tw\`%s\` -> %s`, (utility, expected) => { 39 | expect(tw.style(utility)).toEqual(expected); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/__tests__/margin.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`margin`, () => { 5 | let tw = create(); 6 | beforeEach(() => { 7 | tw = create(); 8 | tw.setWindowDimensions({ width: 800, height: 600 }); 9 | }); 10 | 11 | const cases: Array<[string, Record]> = [ 12 | [ 13 | `m-auto`, 14 | { 15 | marginTop: `auto`, 16 | marginBottom: `auto`, 17 | marginLeft: `auto`, 18 | marginRight: `auto`, 19 | }, 20 | ], 21 | [`mt-1`, { marginTop: 4 }], 22 | [`mt-0.5`, { marginTop: 2 }], 23 | [`mt-0.25`, { marginTop: 1 }], 24 | [`mt-1.25`, { marginTop: 5 }], 25 | [`ml-0.5`, { marginLeft: 2 }], 26 | [`ml-0.25`, { marginLeft: 1 }], 27 | [`ml-1.25`, { marginLeft: 5 }], 28 | [`mt-auto`, { marginTop: `auto` }], 29 | [`mb-auto`, { marginBottom: `auto` }], 30 | [`ml-auto`, { marginLeft: `auto` }], 31 | [`mr-auto`, { marginRight: `auto` }], 32 | [`mx-auto`, { marginRight: `auto`, marginLeft: `auto` }], 33 | [`my-auto`, { marginTop: `auto`, marginBottom: `auto` }], 34 | [`mt-px`, { marginTop: 1 }], 35 | [`ml-[333px]`, { marginLeft: 333 }], 36 | [`-ml-1`, { marginLeft: -4 }], 37 | [`mb-[100vh]`, { marginBottom: 600 }], 38 | [`ml-[100vw]`, { marginLeft: 800 }], 39 | [`mr-[1vw]`, { marginRight: 8 }], 40 | ]; 41 | 42 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 43 | expect(tw.style(utility)).toEqual(expected); 44 | }); 45 | 46 | test(`margin w/extended theme`, () => { 47 | tw = create({ 48 | theme: { 49 | extend: { 50 | spacing: { 51 | custom: `1000rem`, 52 | }, 53 | }, 54 | }, 55 | }); 56 | 57 | expect(tw`m-custom`).toEqual({ 58 | marginTop: 16000, 59 | marginBottom: 16000, 60 | marginLeft: 16000, 61 | marginRight: 16000, 62 | }); 63 | 64 | expect(tw`m-1`).toEqual({ 65 | marginTop: 4, 66 | marginBottom: 4, 67 | marginLeft: 4, 68 | marginRight: 4, 69 | }); 70 | 71 | expect(tw`m-0.5`).toEqual({ 72 | marginTop: 2, 73 | marginBottom: 2, 74 | marginLeft: 2, 75 | marginRight: 2, 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/__tests__/memo-buster.spec.tsx: -------------------------------------------------------------------------------- 1 | import TestRenderer from 'react-test-renderer'; 2 | import { describe, it, expect } from '@jest/globals'; 3 | import React from 'react'; 4 | import { create, useDeviceContext, useAppColorScheme } from '../'; 5 | 6 | describe(`memo busting`, () => { 7 | let tw = create(); 8 | beforeEach(() => (tw = create())); 9 | 10 | const MemoComponent: React.FC = React.memo(() => ( 11 | <>{tw.prefixMatch(`dark`) ? `memo:match:dark` : `memo:no-match:dark`} 12 | )); 13 | 14 | const Toggler: React.FC<{ onPress: () => unknown }> = () => null; 15 | 16 | const Component: React.FC<{ initial: 'light' | 'dark' | 'device' }> = ({ initial }) => { 17 | useDeviceContext(tw, { 18 | observeDeviceColorSchemeChanges: false, 19 | initialColorScheme: initial, 20 | }); 21 | const [, toggleColorScheme] = useAppColorScheme(tw); 22 | return ( 23 | <> 24 | toggleColorScheme()} /> 25 | {tw.prefixMatch(`dark`) ? `match:dark` : `no-match:dark`} 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | it(`breaks memoization properly, starting "light"`, () => { 33 | const renderer = TestRenderer.create(); 34 | expect(assertArray(renderer.toJSON())).toEqual([ 35 | `no-match:dark`, 36 | `memo:no-match:dark`, 37 | `memo:no-match:dark`, 38 | ]); 39 | TestRenderer.act(() => { 40 | renderer.root.findByType(Toggler).props.onPress(); 41 | }); 42 | expect(assertArray(renderer.toJSON())).toEqual([ 43 | `match:dark`, 44 | `memo:no-match:dark`, // <-- memo not busted 45 | `memo:match:dark`, // <-- memo busted 46 | ]); 47 | }); 48 | 49 | it(`breaks memoization properly, starting "dark"`, () => { 50 | const renderer = TestRenderer.create(); 51 | expect(assertArray(renderer.toJSON())).toEqual([ 52 | `match:dark`, 53 | `memo:match:dark`, 54 | `memo:match:dark`, 55 | ]); 56 | TestRenderer.act(() => { 57 | renderer.root.findByType(Toggler).props.onPress(); 58 | }); 59 | expect(assertArray(renderer.toJSON())).toEqual([ 60 | `no-match:dark`, 61 | `memo:match:dark`, // <-- memo not busted 62 | `memo:no-match:dark`, // <-- memo busted 63 | ]); 64 | }); 65 | }); 66 | 67 | function assertArray(value: T | T[]): T[] { 68 | if (!Array.isArray(value)) throw new Error(`expected array, got ${value}`); 69 | return value; 70 | } 71 | -------------------------------------------------------------------------------- /src/__tests__/min-max-dims.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`min/max width/height`, () => { 5 | let tw = create(); 6 | beforeEach(() => { 7 | tw = create(); 8 | tw.setWindowDimensions({ width: 800, height: 600 }); 9 | }); 10 | 11 | const cases: Array<[string, Record]> = [ 12 | [`min-w-0`, { minWidth: 0 }], 13 | [`min-w-full`, { minWidth: `100%` }], 14 | [`min-h-0`, { minHeight: 0 }], 15 | [`min-h-full`, { minHeight: `100%` }], 16 | 17 | // arbitrary min height/width 18 | [`min-w-1/4`, { minWidth: `25%` }], 19 | [`min-w-1/2`, { minWidth: `50%` }], 20 | [`min-w-1`, { minWidth: 4 }], 21 | [`min-h-1/4`, { minHeight: `25%` }], 22 | [`min-h-1`, { minHeight: 4 }], 23 | [`min-w-[50%]`, { minWidth: `50%` }], 24 | [`min-w-[160px]`, { minWidth: 160 }], 25 | 26 | [`max-w-px`, { maxWidth: 1 }], 27 | [`max-w-0`, { maxWidth: 0 }], 28 | [`max-w-screen-sm`, { maxWidth: 640 }], 29 | [`max-w-none`, { maxWidth: `99999%` }], // special case not supported in RN 30 | [`max-w-xs`, { maxWidth: 320 }], 31 | [`max-h-px`, { maxHeight: 1 }], 32 | [`max-h-0`, { maxHeight: 0 }], 33 | [`max-h-0.5`, { maxHeight: 2 }], 34 | [`max-w-full`, { maxWidth: `100%` }], 35 | [`max-h-full`, { maxHeight: `100%` }], 36 | 37 | // vw/vh things, with device.windowDimensions 38 | [`min-h-screen`, { minHeight: 600 }], 39 | [`min-w-screen`, { minWidth: 800 }], 40 | [`min-h-[50vh]`, { minHeight: 300 }], 41 | [`min-w-[25vw]`, { minWidth: 200 }], 42 | ]; 43 | 44 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 45 | expect(tw.style(utility)).toEqual(expected); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/__tests__/prefix-match.spec.ts: -------------------------------------------------------------------------------- 1 | import rn from 'react-native'; 2 | import { describe, test, expect } from '@jest/globals'; 3 | import { create } from '../'; 4 | 5 | jest.mock(`react-native`, () => ({ 6 | Platform: { OS: `ios` }, 7 | })); 8 | 9 | describe(`tw.prefixMatch()`, () => { 10 | let tw = create(); 11 | beforeEach(() => (tw = create())); 12 | 13 | test(`unknown prefixes return false`, () => { 14 | expect(tw.prefixMatch(`foo`)).toBe(false); 15 | expect(tw.prefixMatch(`bar`)).toBe(false); 16 | expect(tw.prefixMatch(`baz`)).toBe(false); 17 | }); 18 | 19 | test(`platform prefixes`, () => { 20 | rn.Platform.OS = `ios`; 21 | tw = create(); 22 | expect(tw.prefixMatch(`ios`)).toBe(true); 23 | expect(tw.prefixMatch(`android`)).toBe(false); 24 | rn.Platform.OS = `android`; 25 | tw = create(); 26 | expect(tw.prefixMatch(`ios`)).toBe(false); 27 | expect(tw.prefixMatch(`android`)).toBe(true); 28 | expect(tw`web:self-center`).toEqual({}); 29 | expect(tw`not-valid-util`).toEqual({}); 30 | rn.Platform.OS = `web`; 31 | tw = create(); 32 | expect(tw.prefixMatch(`ios`)).toBe(false); 33 | expect(tw.prefixMatch(`android`)).toBe(false); 34 | expect(tw.prefixMatch(`web`)).toBe(true); 35 | expect(tw`web:self-center`).toEqual({ alignSelf: `center` }); 36 | }); 37 | 38 | test(`breakpoint prefixes`, () => { 39 | tw = create({ theme: { screens: { md: `600px`, lg: `800px`, xl: `1000px` } } }); 40 | tw.setWindowDimensions({ width: 801, height: 600 }); 41 | expect(tw.prefixMatch(`md`)).toBe(true); 42 | expect(tw.prefixMatch(`lg`)).toBe(true); 43 | expect(tw.prefixMatch(`xl`)).toBe(false); 44 | expect(tw.prefixMatch(`landscape`)).toBe(true); 45 | expect(tw.prefixMatch(`portrait`)).toBe(false); 46 | }); 47 | 48 | test(`arbitrary breakpoint prefixes`, () => { 49 | tw.setWindowDimensions({ width: 800, height: 600 }); 50 | expect(tw.prefixMatch(`min-h-[500px]`)).toBe(true); 51 | expect(tw.prefixMatch(`max-h-[500px]`)).toBe(false); 52 | expect(tw.prefixMatch(`min-w-[500px]`)).toBe(true); 53 | expect(tw.prefixMatch(`max-w-[500px]`)).toBe(false); 54 | }); 55 | 56 | test(`multiple prefixes`, () => { 57 | rn.Platform.OS = `ios`; 58 | tw = create(); 59 | tw.setWindowDimensions({ width: 800, height: 600 }); 60 | expect(tw.prefixMatch(`min-w-[500px]`, `max-w-[600px]`)).toBe(false); 61 | expect(tw.prefixMatch(`min-w-[500px]`, `max-w-[900px]`)).toBe(true); 62 | expect(tw.prefixMatch(`min-w-[500px]`, `ios`)).toBe(true); 63 | expect(tw.prefixMatch(`min-w-[500px]`, `android`)).toBe(false); 64 | }); 65 | 66 | test(`retina prefix`, () => { 67 | tw.setPixelDensity(1); 68 | expect(tw.prefixMatch(`retina`)).toBe(false); 69 | tw.setPixelDensity(2); 70 | expect(tw.prefixMatch(`retina`)).toBe(true); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/__tests__/screens.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from '@jest/globals'; 2 | import type { TwTheme } from '../tw-config'; 3 | import screens from '../screens'; 4 | 5 | describe(`screens()`, () => { 6 | const cases: Array<[TwTheme['screens'], ReturnType]> = [ 7 | [ 8 | { 9 | sm: `640px`, 10 | md: `768px`, 11 | lg: `1024px`, 12 | xl: `1280px`, 13 | }, 14 | { 15 | sm: [640, Infinity, 0], 16 | md: [768, Infinity, 1], 17 | lg: [1024, Infinity, 2], 18 | xl: [1280, Infinity, 3], 19 | }, 20 | ], 21 | [{ jared: `100px` }, { jared: [100, Infinity, 0] }], 22 | [ 23 | { 24 | '2xl': { max: `1535px` }, 25 | xl: { max: `1279px` }, 26 | lg: { max: `1023px` }, 27 | md: { max: `767px` }, 28 | sm: { max: `639px` }, 29 | }, 30 | { 31 | sm: [0, 639, 0], 32 | md: [0, 767, 1], 33 | lg: [0, 1023, 2], 34 | xl: [0, 1279, 3], 35 | '2xl': [0, 1535, 4], 36 | }, 37 | ], 38 | [ 39 | { 40 | sm: { min: `640px`, max: `767px` }, 41 | lg: { min: `1024px`, max: `1279px` }, 42 | md: { min: `768px`, max: `1023px` }, 43 | xl: { min: `1280px`, max: `1535px` }, 44 | '2xl': { min: `1536px` }, 45 | }, 46 | { 47 | sm: [640, 767, 0], 48 | md: [768, 1023, 1], 49 | lg: [1024, 1279, 2], 50 | xl: [1280, 1535, 3], 51 | '2xl': [1536, Infinity, 4], 52 | }, 53 | ], 54 | ]; 55 | 56 | // https://tailwindcss.com/docs/breakpoints#custom-media-queries 57 | test.each(cases)(`converts tw screens to ranges`, (input, expected) => { 58 | expect(screens(input)).toEqual(expected); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/__tests__/shadow.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`shadow utilities`, () => { 5 | let tw = create(); 6 | beforeEach(() => (tw = create())); 7 | 8 | const cases: Array<[string, Record>]> = 9 | [ 10 | [`shadow-white`, { shadowColor: `#fff` }], 11 | [`shadow-black`, { shadowColor: `#000` }], 12 | [`shadow-[#eaeaea]`, { shadowColor: `#eaeaea` }], 13 | [`shadow-black shadow-color-opacity-50`, { shadowColor: `rgba(0, 0, 0, 0.5)` }], 14 | [`shadow-opacity-50`, { shadowOpacity: 0.5 }], 15 | [`shadow-offset-1`, { shadowOffset: { width: 4, height: 4 } }], 16 | [`shadow-offset-[333px]`, { shadowOffset: { width: 333, height: 333 } }], 17 | [`shadow-offset-[23px]/[33px]`, { shadowOffset: { width: 23, height: 33 } }], 18 | [`shadow-offset-2/3`, { shadowOffset: { width: 8, height: 12 } }], 19 | [`shadow-radius-1`, { shadowRadius: 4 }], 20 | [`shadow-radius-[13px]`, { shadowRadius: 13 }], 21 | ]; 22 | 23 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 24 | expect(tw.style(utility)).toEqual(expected); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/__tests__/simple-mappings.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import type { Style } from '../types'; 3 | import { create } from '../'; 4 | 5 | describe(`simple style mappings`, () => { 6 | const tw = create(); 7 | 8 | const cases: Array<[string, Style]> = [ 9 | [`items-start`, { alignItems: `flex-start` }], 10 | [`items-end`, { alignItems: `flex-end` }], 11 | [`items-center`, { alignItems: `center` }], 12 | [`items-baseline`, { alignItems: `baseline` }], 13 | [`items-stretch`, { alignItems: `stretch` }], 14 | [`content-start`, { alignContent: `flex-start` }], 15 | [`content-end`, { alignContent: `flex-end` }], 16 | [`content-between`, { alignContent: `space-between` }], 17 | [`content-around`, { alignContent: `space-around` }], 18 | [`content-stretch`, { alignContent: `stretch` }], 19 | [`content-center`, { alignContent: `center` }], 20 | [`self-auto`, { alignSelf: `auto` }], 21 | [`self-start`, { alignSelf: `flex-start` }], 22 | [`self-end`, { alignSelf: `flex-end` }], 23 | [`self-center`, { alignSelf: `center` }], 24 | [`self-stretch`, { alignSelf: `stretch` }], 25 | [`self-baseline`, { alignSelf: `baseline` }], 26 | [`direction-inherit`, { direction: `inherit` }], 27 | [`direction-ltr`, { direction: `ltr` }], 28 | [`direction-rtl`, { direction: `rtl` }], 29 | [`hidden`, { display: `none` }], 30 | [`flex`, { display: `flex` }], 31 | [`flex-row`, { flexDirection: `row` }], 32 | [`flex-row-reverse`, { flexDirection: `row-reverse` }], 33 | [`flex-col`, { flexDirection: `column` }], 34 | [`flex-col-reverse`, { flexDirection: `column-reverse` }], 35 | [`flex-wrap`, { flexWrap: `wrap` }], 36 | [`flex-wrap-reverse`, { flexWrap: `wrap-reverse` }], 37 | [`flex-nowrap`, { flexWrap: `nowrap` }], 38 | [`justify-start`, { justifyContent: `flex-start` }], 39 | [`justify-end`, { justifyContent: `flex-end` }], 40 | [`justify-center`, { justifyContent: `center` }], 41 | [`justify-between`, { justifyContent: `space-between` }], 42 | [`justify-around`, { justifyContent: `space-around` }], 43 | [`justify-evenly`, { justifyContent: `space-evenly` }], 44 | [`overflow-hidden`, { overflow: `hidden` }], 45 | [`overflow-visible`, { overflow: `visible` }], 46 | [`overflow-scroll`, { overflow: `scroll` }], 47 | [`absolute`, { position: `absolute` }], 48 | [`relative`, { position: `relative` }], 49 | [`italic`, { fontStyle: `italic` }], 50 | [`not-italic`, { fontStyle: `normal` }], 51 | 52 | [`font-thin`, { fontWeight: `100` }], 53 | [`font-100`, { fontWeight: `100` }], // not in tailwindcss 54 | [`font-extralight`, { fontWeight: `200` }], 55 | [`font-200`, { fontWeight: `200` }], // not in tailwindcss 56 | [`font-light`, { fontWeight: `300` }], 57 | [`font-300`, { fontWeight: `300` }], // not in tailwindcss 58 | [`font-normal`, { fontWeight: `normal` }], 59 | [`font-400`, { fontWeight: `400` }], // not in tailwindcss 60 | [`font-medium`, { fontWeight: `500` }], 61 | [`font-500`, { fontWeight: `500` }], // not in tailwindcss 62 | [`font-semibold`, { fontWeight: `600` }], 63 | [`font-600`, { fontWeight: `600` }], // not in tailwindcss 64 | [`font-bold`, { fontWeight: `bold` }], 65 | [`font-700`, { fontWeight: `700` }], // not in tailwindcss 66 | [`font-extrabold`, { fontWeight: `800` }], 67 | [`font-800`, { fontWeight: `800` }], // not in tailwindcss 68 | [`font-black`, { fontWeight: `900` }], 69 | [`font-900`, { fontWeight: `900` }], // not in tailwindcss 70 | 71 | [`include-font-padding`, { includeFontPadding: true }], 72 | [`remove-font-padding`, { includeFontPadding: false }], 73 | 74 | [`text-left`, { textAlign: `left` }], 75 | [`text-center`, { textAlign: `center` }], 76 | [`text-right`, { textAlign: `right` }], 77 | [`text-justify`, { textAlign: `justify` }], 78 | [`text-auto`, { textAlign: `auto` }], // RN only 79 | 80 | [`underline`, { textDecorationLine: `underline` }], 81 | [`line-through`, { textDecorationLine: `line-through` }], 82 | [`no-underline`, { textDecorationLine: `none` }], 83 | 84 | [`uppercase`, { textTransform: `uppercase` }], 85 | [`lowercase`, { textTransform: `lowercase` }], 86 | [`capitalize`, { textTransform: `capitalize` }], 87 | [`normal-case`, { textTransform: `none` }], 88 | 89 | [`align-auto`, { verticalAlign: `auto` }], 90 | [`align-top`, { verticalAlign: `top` }], 91 | [`align-bottom`, { verticalAlign: `bottom` }], 92 | [`align-middle`, { verticalAlign: `middle` }], 93 | 94 | // default box-shadow implementations 95 | [ 96 | `shadow-sm`, 97 | { 98 | shadowOffset: { width: 1, height: 1 }, 99 | shadowColor: `#000`, 100 | shadowRadius: 1, 101 | shadowOpacity: 0.025, 102 | elevation: 1, 103 | }, 104 | ], 105 | [ 106 | `shadow`, 107 | { 108 | shadowOffset: { width: 1, height: 1 }, 109 | shadowColor: `#000`, 110 | shadowRadius: 1, 111 | shadowOpacity: 0.075, 112 | elevation: 2, 113 | }, 114 | ], 115 | [ 116 | `shadow-md`, 117 | { 118 | shadowOffset: { width: 1, height: 1 }, 119 | shadowColor: `#000`, 120 | shadowRadius: 3, 121 | shadowOpacity: 0.125, 122 | elevation: 3, 123 | }, 124 | ], 125 | [ 126 | `shadow-lg`, 127 | { 128 | shadowOffset: { width: 1, height: 1 }, 129 | shadowColor: `#000`, 130 | shadowOpacity: 0.15, 131 | shadowRadius: 8, 132 | elevation: 8, 133 | }, 134 | ], 135 | [ 136 | `shadow-xl`, 137 | { 138 | shadowOffset: { width: 1, height: 1 }, 139 | shadowColor: `#000`, 140 | shadowOpacity: 0.19, 141 | shadowRadius: 20, 142 | elevation: 12, 143 | }, 144 | ], 145 | [ 146 | `shadow-2xl`, 147 | { 148 | shadowOffset: { width: 1, height: 1 }, 149 | shadowColor: `#000`, 150 | shadowOpacity: 0.25, 151 | shadowRadius: 30, 152 | elevation: 16, 153 | }, 154 | ], 155 | [ 156 | `shadow-none`, 157 | { 158 | shadowOffset: { width: 0, height: 0 }, 159 | shadowColor: `#000`, 160 | shadowRadius: 0, 161 | shadowOpacity: 0, 162 | elevation: 0, 163 | }, 164 | ], 165 | ]; 166 | 167 | test.each(cases)(`utility %s -> %s`, (utility, style) => { 168 | expect(tw.style(utility)).toEqual(style); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /src/__tests__/transform.spec.ts: -------------------------------------------------------------------------------- 1 | import { create } from '../'; 2 | 3 | describe(`transform utilities`, () => { 4 | let tw = create(); 5 | 6 | beforeEach(() => { 7 | tw = create(); 8 | }); 9 | 10 | describe(`scale`, () => { 11 | const cases: Array<[string, Record<'transform', Record[]>]> = [ 12 | [`scale-0`, { transform: [{ scale: 0 }] }], 13 | [`scale-x-0`, { transform: [{ scaleX: 0 }] }], 14 | [`scale-y-0`, { transform: [{ scaleY: 0 }] }], 15 | [`scale-50`, { transform: [{ scale: 0.5 }] }], 16 | [`scale-x-50`, { transform: [{ scaleX: 0.5 }] }], 17 | [`scale-y-50`, { transform: [{ scaleY: 0.5 }] }], 18 | [`-scale-50`, { transform: [{ scale: -0.5 }] }], 19 | [`-scale-x-50`, { transform: [{ scaleX: -0.5 }] }], 20 | [`-scale-y-50`, { transform: [{ scaleY: -0.5 }] }], 21 | 22 | // arbitrary 23 | [`scale-[1.7]`, { transform: [{ scale: 1.7 }] }], 24 | [`scale-x-[1.7]`, { transform: [{ scaleX: 1.7 }] }], 25 | [`scale-y-[1.7]`, { transform: [{ scaleY: 1.7 }] }], 26 | 27 | // not configged 28 | [`scale-99`, { transform: [{ scale: 0.99 }] }], 29 | [`scale-x-99`, { transform: [{ scaleX: 0.99 }] }], 30 | [`scale-y-99`, { transform: [{ scaleY: 0.99 }] }], 31 | ]; 32 | 33 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 34 | expect(tw.style(utility)).toMatchObject(expected); 35 | }); 36 | 37 | test(`scale w/extended theme`, () => { 38 | tw = create({ 39 | theme: { 40 | extend: { 41 | scale: { 42 | custom: `1.99`, 43 | }, 44 | }, 45 | }, 46 | }); 47 | 48 | expect(tw.style(`scale-custom`)).toMatchObject({ transform: [{ scale: 1.99 }] }); 49 | expect(tw.style(`scale-x-custom`)).toMatchObject({ transform: [{ scaleX: 1.99 }] }); 50 | expect(tw.style(`scale-y-custom`)).toMatchObject({ transform: [{ scaleY: 1.99 }] }); 51 | }); 52 | 53 | test(`combine repeated scale utilities into one`, () => { 54 | expect(tw.style(`scale-50 scale-100`)).toMatchObject({ transform: [{ scale: 1 }] }); 55 | expect(tw.style(`scale-x-50 scale-x-100`)).toMatchObject({ 56 | transform: [{ scaleX: 1 }], 57 | }); 58 | expect(tw.style(`scale-y-50 scale-y-100`)).toMatchObject({ 59 | transform: [{ scaleY: 1 }], 60 | }); 61 | }); 62 | }); 63 | 64 | describe(`rotate`, () => { 65 | const cases: Array<[string, Record<'transform', Record[]>]> = [ 66 | [`rotate-0`, { transform: [{ rotate: `0deg` }] }], 67 | [`rotate-x-0`, { transform: [{ rotateX: `0deg` }] }], 68 | [`rotate-y-0`, { transform: [{ rotateY: `0deg` }] }], 69 | [`rotate-z-0`, { transform: [{ rotateZ: `0deg` }] }], 70 | [`rotate-90`, { transform: [{ rotate: `90deg` }] }], 71 | [`rotate-x-90`, { transform: [{ rotateX: `90deg` }] }], 72 | [`rotate-y-90`, { transform: [{ rotateY: `90deg` }] }], 73 | [`rotate-z-90`, { transform: [{ rotateZ: `90deg` }] }], 74 | [`-rotate-90`, { transform: [{ rotate: `-90deg` }] }], 75 | [`-rotate-x-90`, { transform: [{ rotateX: `-90deg` }] }], 76 | [`-rotate-y-90`, { transform: [{ rotateY: `-90deg` }] }], 77 | [`-rotate-z-90`, { transform: [{ rotateZ: `-90deg` }] }], 78 | 79 | // arbitrary 80 | [`rotate-[90deg]`, { transform: [{ rotate: `90deg` }] }], 81 | [`rotate-x-[90deg]`, { transform: [{ rotateX: `90deg` }] }], 82 | [`rotate-y-[90deg]`, { transform: [{ rotateY: `90deg` }] }], 83 | [`rotate-z-[90deg]`, { transform: [{ rotateZ: `90deg` }] }], 84 | [`rotate-[3.142rad]`, { transform: [{ rotate: `3.142rad` }] }], 85 | [`rotate-x-[3.142rad]`, { transform: [{ rotateX: `3.142rad` }] }], 86 | [`rotate-y-[3.142rad]`, { transform: [{ rotateY: `3.142rad` }] }], 87 | [`rotate-z-[3.142rad]`, { transform: [{ rotateZ: `3.142rad` }] }], 88 | 89 | // not configged 90 | [`rotate-99`, { transform: [{ rotate: `99deg` }] }], 91 | [`rotate-x-99`, { transform: [{ rotateX: `99deg` }] }], 92 | [`rotate-y-99`, { transform: [{ rotateY: `99deg` }] }], 93 | [`rotate-z-99`, { transform: [{ rotateZ: `99deg` }] }], 94 | ]; 95 | 96 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 97 | expect(tw.style(utility)).toMatchObject(expected); 98 | }); 99 | 100 | test(`rotate w/extended theme`, () => { 101 | tw = create({ 102 | theme: { 103 | extend: { 104 | rotate: { 105 | custom: `1.99rad`, 106 | }, 107 | }, 108 | }, 109 | }); 110 | 111 | expect(tw.style(`rotate-custom`)).toMatchObject({ 112 | transform: [{ rotate: `1.99rad` }], 113 | }); 114 | expect(tw.style(`rotate-x-custom`)).toMatchObject({ 115 | transform: [{ rotateX: `1.99rad` }], 116 | }); 117 | expect(tw.style(`rotate-y-custom`)).toMatchObject({ 118 | transform: [{ rotateY: `1.99rad` }], 119 | }); 120 | expect(tw.style(`rotate-z-custom`)).toMatchObject({ 121 | transform: [{ rotateZ: `1.99rad` }], 122 | }); 123 | }); 124 | 125 | test(`combine repeated rotate utilities into one`, () => { 126 | expect(tw.style(`rotate-50 rotate-100`)).toMatchObject({ 127 | transform: [{ rotate: `100deg` }], 128 | }); 129 | expect(tw.style(`rotate-x-50 rotate-x-100`)).toMatchObject({ 130 | transform: [{ rotateX: `100deg` }], 131 | }); 132 | expect(tw.style(`rotate-y-50 rotate-y-100`)).toMatchObject({ 133 | transform: [{ rotateY: `100deg` }], 134 | }); 135 | }); 136 | }); 137 | 138 | describe(`skew`, () => { 139 | const cases: Array<[string, Record<'transform', Record[]>]> = [ 140 | [`skew-x-0`, { transform: [{ skewX: `0deg` }] }], 141 | [`skew-y-0`, { transform: [{ skewY: `0deg` }] }], 142 | [`skew-x-12`, { transform: [{ skewX: `12deg` }] }], 143 | [`skew-y-12`, { transform: [{ skewY: `12deg` }] }], 144 | 145 | // arbitrary 146 | [`skew-x-[90deg]`, { transform: [{ skewX: `90deg` }] }], 147 | [`skew-y-[90deg]`, { transform: [{ skewY: `90deg` }] }], 148 | [`skew-x-[3.142rad]`, { transform: [{ skewX: `3.142rad` }] }], 149 | [`skew-y-[3.142rad]`, { transform: [{ skewY: `3.142rad` }] }], 150 | 151 | // not configged 152 | [`skew-x-99`, { transform: [{ skewX: `99deg` }] }], 153 | [`skew-y-99`, { transform: [{ skewY: `99deg` }] }], 154 | ]; 155 | 156 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 157 | expect(tw.style(utility)).toMatchObject(expected); 158 | }); 159 | 160 | test(`skew w/extended theme`, () => { 161 | tw = create({ 162 | theme: { 163 | extend: { 164 | skew: { 165 | custom: `1.99rad`, 166 | }, 167 | }, 168 | }, 169 | }); 170 | 171 | expect(tw.style(`skew-x-custom`)).toMatchObject({ 172 | transform: [{ skewX: `1.99rad` }], 173 | }); 174 | expect(tw.style(`skew-y-custom`)).toMatchObject({ 175 | transform: [{ skewY: `1.99rad` }], 176 | }); 177 | }); 178 | 179 | test(`combine repeated skew utilities into one`, () => { 180 | expect(tw.style(`skew-x-50 skew-x-100`)).toMatchObject({ 181 | transform: [{ skewX: `100deg` }], 182 | }); 183 | expect(tw.style(`skew-y-50 skew-y-100`)).toMatchObject({ 184 | transform: [{ skewY: `100deg` }], 185 | }); 186 | }); 187 | }); 188 | 189 | describe(`translate`, () => { 190 | const cases: Array<[string, Record<'transform', Record[]> | object]> = 191 | [ 192 | [`translate-x-0`, { transform: [{ translateX: 0 }] }], 193 | [`translate-y-0`, { transform: [{ translateY: 0 }] }], 194 | [`translate-x-px`, { transform: [{ translateX: 1 }] }], 195 | [`translate-y-px`, { transform: [{ translateY: 1 }] }], 196 | [`translate-x-0.5`, { transform: [{ translateX: 2 }] }], 197 | [`translate-y-0.5`, { transform: [{ translateY: 2 }] }], 198 | [`-translate-x-px`, { transform: [{ translateX: -1 }] }], 199 | [`-translate-y-px`, { transform: [{ translateY: -1 }] }], 200 | [`-translate-x-0.5`, { transform: [{ translateX: -2 }] }], 201 | [`-translate-y-0.5`, { transform: [{ translateY: -2 }] }], 202 | 203 | // arbitrary 204 | [`translate-x-[17rem]`, { transform: [{ translateX: 272 }] }], 205 | [`translate-y-[17rem]`, { transform: [{ translateY: 272 }] }], 206 | 207 | // not configged 208 | [`translate-x-81`, { transform: [{ translateX: (81 / 4) * 16 }] }], 209 | [`translate-y-81`, { transform: [{ translateY: (81 / 4) * 16 }] }], 210 | 211 | // unsupported 212 | [`translate-x-full`, {}], 213 | [`translate-y-full`, {}], 214 | [`translate-x-1/2`, {}], 215 | [`translate-y-1/2`, {}], 216 | ]; 217 | 218 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 219 | expect(tw.style(utility)).toMatchObject(expected); 220 | }); 221 | 222 | test(`translate w/extended theme`, () => { 223 | tw = create({ 224 | theme: { 225 | extend: { 226 | translate: { 227 | '4.25': `17rem`, 228 | }, 229 | }, 230 | }, 231 | }); 232 | 233 | expect(tw.style(`translate-x-4.25`)).toMatchObject({ 234 | transform: [{ translateX: 272 }], 235 | }); 236 | expect(tw.style(`translate-y-4.25`)).toMatchObject({ 237 | transform: [{ translateY: 272 }], 238 | }); 239 | }); 240 | 241 | test(`combine repeated translate utilities into one`, () => { 242 | expect(tw.style(`translate-x-2 translate-x-4`)).toMatchObject({ 243 | transform: [{ translateX: 16 }], 244 | }); 245 | expect(tw.style(`translate-y-2 translate-y-4`)).toMatchObject({ 246 | transform: [{ translateY: 16 }], 247 | }); 248 | }); 249 | }); 250 | 251 | test(`combine multiple transform utilities `, () => { 252 | expect( 253 | tw.style( 254 | `scale-50 scale-x-100 scale-y-150 rotate-0 rotate-x-90 rotate-y-45 skew-x-99 skew-y-99 translate-x-px translate-y-px`, 255 | ), 256 | ).toMatchObject({ 257 | transform: [ 258 | { scale: 0.5 }, 259 | { scaleX: 1 }, 260 | { scaleY: 1.5 }, 261 | { rotate: `0deg` }, 262 | { rotateX: `90deg` }, 263 | { rotateY: `45deg` }, 264 | { skewX: `99deg` }, 265 | { skewY: `99deg` }, 266 | { translateX: 1 }, 267 | { translateY: 1 }, 268 | ], 269 | }); 270 | }); 271 | 272 | test(`transform-none`, () => { 273 | expect( 274 | tw.style( 275 | `scale-50 scale-x-100 scale-y-150 rotate-0 rotate-x-90 rotate-y-45 skew-x-99 skew-y-99 translate-x-px translate-y-px transform-none`, 276 | ), 277 | ).toMatchObject({ transform: [] }); 278 | }); 279 | }); 280 | -------------------------------------------------------------------------------- /src/__tests__/tw.spec.ts: -------------------------------------------------------------------------------- 1 | import rn from 'react-native'; 2 | import { describe, test, expect } from '@jest/globals'; 3 | import type { ViewStyle } from 'react-native'; 4 | import type { TwConfig } from '../tw-config'; 5 | import { create } from '../'; 6 | 7 | jest.mock(`react-native`, () => ({ 8 | Platform: { OS: `ios` }, 9 | })); 10 | 11 | describe(`tw`, () => { 12 | let tw = create(); 13 | beforeEach(() => (tw = create())); 14 | 15 | test(`cached utilities are === to prevent needless re-renders`, () => { 16 | expect(tw`w-0`).toBe(tw`w-0`); 17 | }); 18 | 19 | test(`interpolation supports falsy numbers`, () => { 20 | const falsyNumber = 0; 21 | expect(tw`opacity-${falsyNumber}`).toEqual({ opacity: 0 }); 22 | }); 23 | 24 | test(`media queries`, () => { 25 | const config: TwConfig = { theme: { screens: { md: `768px` } } }; 26 | tw = create(config); 27 | tw.setWindowDimensions({ width: 500, height: 500 }); 28 | expect(tw`md:text-lg text-xs`).toMatchObject({ fontSize: 12 }); 29 | tw.setWindowDimensions({ width: 800, height: 500 }); 30 | expect(tw`md:text-lg text-xs`).toMatchObject({ fontSize: 18 }); 31 | }); 32 | 33 | test(`media queries boundaries`, () => { 34 | tw = create(); 35 | // default breakpoints 36 | const utilities = `text-xs sm:text-lg md:text-xl lg:text-2xl xl:text-3xl`; 37 | expect(tw.style(utilities)).toMatchObject({ fontSize: 12 }); 38 | tw.setWindowDimensions({ width: 500, height: 500 }); 39 | expect(tw.style(utilities)).toMatchObject({ fontSize: 12 }); 40 | tw.setWindowDimensions({ width: 639, height: 500 }); 41 | expect(tw.style(utilities)).toMatchObject({ fontSize: 12 }); 42 | tw.setWindowDimensions({ width: 640, height: 500 }); 43 | expect(tw.style(utilities)).toMatchObject({ fontSize: 18 }); 44 | tw.setWindowDimensions({ width: 767, height: 500 }); 45 | expect(tw.style(utilities)).toMatchObject({ fontSize: 18 }); 46 | tw.setWindowDimensions({ width: 768, height: 500 }); 47 | expect(tw.style(utilities)).toMatchObject({ fontSize: 20 }); 48 | tw.setWindowDimensions({ width: 1023, height: 500 }); 49 | expect(tw.style(utilities)).toMatchObject({ fontSize: 20 }); 50 | tw.setWindowDimensions({ width: 1024, height: 500 }); 51 | expect(tw.style(utilities)).toMatchObject({ fontSize: 24 }); 52 | tw.setWindowDimensions({ width: 1279, height: 500 }); 53 | expect(tw.style(utilities)).toMatchObject({ fontSize: 24 }); 54 | tw.setWindowDimensions({ width: 1280, height: 500 }); 55 | expect(tw.style(utilities)).toMatchObject({ fontSize: 30 }); 56 | // custom breakpoints 57 | tw = create({ theme: { screens: { custom: `555px` } } }); 58 | tw.setWindowDimensions({ width: 554, height: 500 }); 59 | expect(tw`text-xs custom:text-lg`).toMatchObject({ fontSize: 12 }); 60 | tw.setWindowDimensions({ width: 555, height: 500 }); 61 | expect(tw`text-xs custom:text-lg`).toMatchObject({ fontSize: 18 }); 62 | }); 63 | 64 | test(`multiple media queries`, () => { 65 | const config: TwConfig = { theme: { screens: { sm: `640px`, md: `768px` } } }; 66 | tw = create(config); 67 | tw.setWindowDimensions({ width: 800, height: 0 }); 68 | expect(tw`text-xs sm:text-md md:text-lg`).toMatchObject({ fontSize: 18 }); 69 | // out of order 70 | expect(tw`md:text-lg sm:text-base text-xs`).toMatchObject({ fontSize: 18 }); 71 | expect(tw`sm:text-base md:text-lg text-xs`).toMatchObject({ fontSize: 18 }); 72 | expect(tw`md:text-lg text-xs sm:text-base`).toMatchObject({ fontSize: 18 }); 73 | }); 74 | 75 | test(`media queries + dependent style`, () => { 76 | const config: TwConfig = { theme: { screens: { md: `768px` } } }; 77 | tw = create(config); 78 | tw.setWindowDimensions({ width: 800, height: 0 }); 79 | expect(tw`text-xs leading-none md:leading-tight`).toEqual({ 80 | fontSize: 12, 81 | lineHeight: 15, 82 | }); 83 | }); 84 | 85 | test(`orientation utilities`, () => { 86 | tw = create(); 87 | tw.setWindowDimensions({ width: 600, height: 800 }); 88 | expect(tw`mt-0 landscape:mt-1`).toEqual({ marginTop: 0 }); 89 | expect(tw`landscape:mt-1 mt-0`).toEqual({ marginTop: 0 }); 90 | expect(tw`landscape:mt-1 mt-0 portrait:mt-2`).toEqual({ marginTop: 8 }); 91 | expect(tw`mt-0 portrait:mt-2 landscape:mt-1`).toEqual({ marginTop: 8 }); 92 | tw = create(); 93 | tw.setWindowDimensions({ width: 800, height: 600 }); 94 | expect(tw`mt-0 landscape:mt-1`).toEqual({ marginTop: 4 }); 95 | expect(tw`landscape:mt-1 mt-0`).toEqual({ marginTop: 4 }); 96 | tw = create(); 97 | expect(tw`mt-0 landscape:mt-1 portrait:mt-2`).toEqual({ marginTop: 0 }); 98 | expect(tw`landscape:mt-1 mt-0 portrait:mt-2`).toEqual({ marginTop: 0 }); 99 | }); 100 | 101 | test(`multiple prefixes`, () => { 102 | rn.Platform.OS = `android`; 103 | const config: TwConfig = { theme: { screens: { md: `768px` } } }; 104 | tw = create(config); 105 | tw.setWindowDimensions({ width: 800, height: 0 }); 106 | tw.setColorScheme(`dark`); 107 | expect( 108 | tw`android:md:text-xs android:text-2xl ios:text-lg android:dark:mt-2 mt-1`, 109 | ).toMatchObject({ 110 | fontSize: 12, 111 | marginTop: 8, 112 | }); 113 | }); 114 | 115 | test(`platform-matching`, () => { 116 | rn.Platform.OS = `ios`; 117 | tw = create(); 118 | expect(tw`android:text-lg ios:text-xs`).toMatchObject({ fontSize: 12 }); 119 | rn.Platform.OS = `android`; 120 | tw = create(); 121 | expect(tw`android:text-lg ios:text-xs`).toMatchObject({ fontSize: 18 }); 122 | }); 123 | 124 | const relativeLineHeight: Array<[string, number, number]> = [ 125 | [`text-xs leading-none`, 12, 12], 126 | [`text-xs leading-tight`, 12, 15], 127 | [`text-xs leading-snug`, 12, 16.5], 128 | [`text-xs leading-normal`, 12, 18], 129 | [`text-xs leading-relaxed`, 12, 19.5], 130 | [`text-xs leading-loose`, 12, 24], 131 | ]; 132 | 133 | test.each(relativeLineHeight)( 134 | `line-height derived from font size "%s"`, 135 | (classes, fontSize, lineHeight) => { 136 | expect(tw`${classes}`).toEqual({ fontSize, lineHeight }); 137 | }, 138 | ); 139 | 140 | const absoluteLineHeight: Array<[string, number]> = [ 141 | [`leading-3`, 12], 142 | [`leading-4`, 16], 143 | [`leading-5`, 20], 144 | [`leading-6`, 24], 145 | [`leading-7`, 28], 146 | [`leading-8`, 32], 147 | [`leading-9`, 36], 148 | [`leading-[333px]`, 333], 149 | ]; 150 | 151 | test.each(absoluteLineHeight)( 152 | `absolute line-height "%s" -> %dpx`, 153 | (classes, lineHeight) => { 154 | expect(tw`${classes}`).toEqual({ lineHeight }); 155 | }, 156 | ); 157 | 158 | test(`customized line-height`, () => { 159 | tw = create({ theme: { lineHeight: { '5': `2rem`, huge: `400px` } } }); 160 | expect(tw`leading-5`).toEqual({ lineHeight: 32 }); 161 | expect(tw`leading-huge`).toEqual({ lineHeight: 400 }); 162 | }); 163 | 164 | test(`font-family`, () => { 165 | expect(tw`font-sans`).toEqual({ fontFamily: `ui-sans-serif` }); 166 | tw = create({ theme: { fontFamily: { sans: `font1`, serif: [`font2`, `font3`] } } }); 167 | expect(tw`font-sans`).toEqual({ fontFamily: `font1` }); 168 | expect(tw`font-serif`).toEqual({ fontFamily: `font2` }); 169 | }); 170 | 171 | test(`negated values`, () => { 172 | expect(tw`-mt-1 -pb-2`).toEqual({ marginTop: -4, paddingBottom: -8 }); 173 | }); 174 | 175 | test(`arbitrary value`, () => { 176 | expect(tw`mt-[333px]`).toEqual({ marginTop: 333 }); 177 | expect(tw`mt-[-333px]`).toEqual({ marginTop: -333 }); 178 | expect(tw`-mt-[333px]`).toEqual({ marginTop: -333 }); 179 | }); 180 | 181 | test(`elevation (android-only)`, () => { 182 | expect(tw`elevation-1`).toEqual({ elevation: 1 }); 183 | expect(tw`elevation-2`).toEqual({ elevation: 2 }); 184 | expect(tw`elevation-17`).toMatchObject({ elevation: 17 }); 185 | }); 186 | 187 | describe(`font-variant-numeric support`, () => { 188 | test(`oldstyle-nums`, () => { 189 | expect(tw`oldstyle-nums`).toEqual({ fontVariant: [`oldstyle-nums`] }); 190 | }); 191 | 192 | test(`lining-nums`, () => { 193 | expect(tw`lining-nums`).toEqual({ fontVariant: [`lining-nums`] }); 194 | }); 195 | 196 | test(`tabular-nums`, () => { 197 | expect(tw`tabular-nums`).toEqual({ fontVariant: [`tabular-nums`] }); 198 | }); 199 | 200 | test(`proportional-nums`, () => { 201 | expect(tw`proportional-nums`).toEqual({ fontVariant: [`proportional-nums`] }); 202 | }); 203 | 204 | test(`multiple font variants`, () => { 205 | expect(tw`oldstyle-nums lining-nums tabular-nums proportional-nums`).toEqual({ 206 | fontVariant: [ 207 | `oldstyle-nums`, 208 | `lining-nums`, 209 | `tabular-nums`, 210 | `proportional-nums`, 211 | ], 212 | }); 213 | }); 214 | }); 215 | 216 | test(`opacity-X`, () => { 217 | expect(tw`opacity-0`).toEqual({ opacity: 0 }); 218 | expect(tw`opacity-5`).toEqual({ opacity: 0.05 }); 219 | expect(tw`opacity-50`).toEqual({ opacity: 0.5 }); 220 | expect(tw`opacity-100`).toEqual({ opacity: 1 }); 221 | // arbitrary 222 | expect(tw`opacity-73`).toEqual({ opacity: 0.73 }); 223 | }); 224 | 225 | test(`tw.color()`, () => { 226 | expect(tw.color(`black`)).toBe(`#000`); 227 | expect(tw.color(`bg-black`)).toBe(`#000`); // incorrect usage, but still works 228 | expect(tw.color(`border-black`)).toBe(`#000`); // incorrect usage, but still works 229 | expect(tw.color(`text-black`)).toBe(`#000`); // incorrect usage, but still works 230 | expect(tw.color(`white/25`)).toBe(`rgba(255, 255, 255, 0.25)`); 231 | expect(tw.color(`black opacity-50`)).toBe(`rgba(0, 0, 0, 0.5)`); 232 | expect(tw.color(`red-500`)).toBe(`#ef4444`); 233 | // @see https://github.com/jaredh159/tailwind-react-native-classnames/issues/273 234 | tw = create({ 235 | theme: { extend: { colors: { text: { primary: `#F9E` } } } }, 236 | }); 237 | expect(tw.color(`text-primary`)).toBe(`#F9E`); 238 | }); 239 | 240 | test(`merging in user styles`, () => { 241 | expect(tw.style(`bg-black`, { textShadowColor: `#ff0` })).toEqual({ 242 | backgroundColor: `#000`, 243 | textShadowColor: `#ff0`, 244 | }); 245 | 246 | expect(tw.style({ lineHeight: 16 }, { elevation: 3 })).toEqual({ 247 | lineHeight: 16, 248 | elevation: 3, 249 | }); 250 | }); 251 | 252 | test(`mapped style after platform prefix works`, () => { 253 | rn.Platform.OS = `ios`; 254 | tw = create(); 255 | expect(tw`ios:hidden`).toEqual({ display: `none` }); 256 | }); 257 | 258 | test(`style-object only doesn't confuse cache`, () => { 259 | expect(tw.style({ width: 90 })).toEqual({ width: 90 }); 260 | expect(tw.style({ width: 40 })).toEqual({ width: 40 }); 261 | }); 262 | 263 | test(`rn style objects don't confuse cache`, () => { 264 | expect(tw.style(`pt-1`, { width: 90 })).toEqual({ paddingTop: 4, width: 90 }); 265 | expect(tw.style(`pt-1`, { width: 100 })).toEqual({ paddingTop: 4, width: 100 }); 266 | }); 267 | 268 | test(`unknown prefixes produce null styles`, () => { 269 | expect(tw`w-1 foo:w-2`.width).toBe(4); 270 | expect(tw`lol:hidden`.display).toBeUndefined(); 271 | }); 272 | 273 | test(`retina prefix`, () => { 274 | expect(tw`w-1 retina:w-2`.width).toBe(4); 275 | expect(tw`retina:w-2 w-1`.width).toBe(4); 276 | tw.setPixelDensity(1); 277 | expect(tw`w-1 retina:w-2`.width).toBe(4); 278 | expect(tw`retina:w-2 w-1`.width).toBe(4); 279 | tw.setPixelDensity(2); 280 | expect(tw`w-1 retina:w-2`.width).toBe(8); 281 | expect(tw`retina:w-2 w-1`.width).toBe(8); 282 | }); 283 | 284 | // @see https://github.com/jaredh159/tailwind-react-native-classnames/issues/60 285 | test(`user styles not mixed into cache`, () => { 286 | expect(tw.style(`text-base mb-4`, { opacity: 0.5 })).toEqual({ 287 | fontSize: 16, 288 | lineHeight: 24, 289 | marginBottom: 16, 290 | opacity: 0.5, 291 | }); 292 | 293 | expect(tw`text-base mb-4`).toEqual({ 294 | fontSize: 16, 295 | lineHeight: 24, 296 | marginBottom: 16, 297 | }); 298 | }); 299 | 300 | // @see https://github.com/jaredh159/tailwind-react-native-classnames/issues/96 301 | test(`no typescript error when passing ViewStyle to tw.style()`, () => { 302 | const rnViewStyle: ViewStyle = { backgroundColor: `#ff000` }; 303 | expect(tw.style(rnViewStyle)).toEqual({ backgroundColor: `#ff000` }); 304 | }); 305 | 306 | // @see https://github.com/jaredh159/tailwind-react-native-classnames/issues/159 307 | test(`override font default styles`, () => { 308 | expect(tw`font-sans`).toEqual({ fontFamily: `ui-sans-serif` }); 309 | expect(tw`font-bold`).toEqual({ fontWeight: `bold` }); 310 | 311 | tw = create({ 312 | theme: { fontFamily: { bold: `Poppins-bold` }, fontWeight: { light: 600 } }, 313 | }); 314 | expect(tw`font-sans`).toEqual({}); // Erased by override font families 315 | expect(tw`font-bold`).toEqual({ fontFamily: `Poppins-bold` }); 316 | expect(tw`font-light`).toEqual({ fontWeight: `600` }); 317 | 318 | tw = create({ 319 | theme: { 320 | extend: { fontFamily: { bold: `Poppins-bold` }, fontWeight: { light: 600 } }, 321 | }, 322 | }); 323 | expect(tw`font-sans`).toEqual({ fontFamily: `ui-sans-serif` }); 324 | expect(tw`font-bold`).toEqual({ fontFamily: `Poppins-bold` }); 325 | expect(tw`font-light`).toEqual({ fontWeight: `600` }); 326 | }); 327 | 328 | test(`ir caching between breakpoints`, () => { 329 | const tw = create(); // one creation, reused so cache is shared 330 | tw.setWindowDimensions({ width: 1100, height: 600 }); // breakpoint=lg 331 | 332 | expect(tw.style(`w-3`)).toEqual({ width: 12 }); 333 | expect(tw.style(`w-1 lg:w-3`)).toEqual({ width: 12 }); 334 | expect(tw.style(`w-1 md:w-2 lg:w-3`)).toEqual({ width: 12 }); 335 | }); 336 | 337 | test(`duplicated style priority`, () => { 338 | expect(tw`bg-white bg-black`).toEqual({ backgroundColor: `#000` }); 339 | expect(tw`bg-white bg-black bg-white`).toEqual({ backgroundColor: `#fff` }); 340 | }); 341 | }); 342 | -------------------------------------------------------------------------------- /src/__tests__/width-height.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`width/height utilities`, () => { 5 | let tw = create(); 6 | beforeEach(() => { 7 | tw = create({ theme: { screens: {} } }); 8 | tw.setWindowDimensions({ width: 800, height: 600 }); 9 | }); 10 | 11 | const cases: Array<[string, Record]> = [ 12 | [`w-0`, { width: 0 }], 13 | [`h-0`, { height: 0 }], 14 | [`w-px`, { width: 1 }], 15 | [`h-px`, { height: 1 }], 16 | [`w-0.25`, { width: 1 }], 17 | [`w-0.5`, { width: 2 }], 18 | [`h-0.5`, { height: 2 }], 19 | [`w-1`, { width: 4 }], 20 | [`h-1`, { height: 4 }], 21 | [`-w-1`, { width: -4 }], 22 | [`-h-1`, { height: -4 }], 23 | [`w-1/2`, { width: `50%` }], 24 | [`h-1/2`, { height: `50%` }], 25 | [`w-3/4`, { width: `75%` }], 26 | [`h-3/4`, { height: `75%` }], 27 | [`w-full`, { width: `100%` }], 28 | [`h-full`, { height: `100%` }], 29 | [`w-auto`, { width: `auto` }], 30 | [`h-auto`, { height: `auto` }], 31 | 32 | // vw/vh 33 | [`h-screen`, { height: 600 }], 34 | [`h-[25vh]`, { height: 150 }], 35 | [`w-screen`, { width: 800 }], 36 | [`w-[50vw]`, { width: 400 }], 37 | 38 | // arbitrary 39 | [`h-[333px]`, { height: 333 }], 40 | 41 | // size-* 42 | [`size-1`, { width: 4, height: 4 }], 43 | [`size-3/4`, { width: `75%`, height: `75%` }], 44 | [`size-[333px]`, { width: 333, height: 333 }], 45 | 46 | // not configged, use 0.25rem = 1 as formula 47 | [`h-81`, { height: (81 / 4) * 16 }], 48 | 49 | // arbitrary fraction, not configged 50 | [`h-17/19`, { height: `${(17 / 19) * 100}%` }], 51 | ]; 52 | 53 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 54 | expect(tw.style(utility)).toEqual(expected); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/__tests__/z-index.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from '@jest/globals'; 2 | import { create } from '../'; 3 | 4 | describe(`z-index utilities`, () => { 5 | let tw = create(); 6 | beforeEach(() => (tw = create())); 7 | 8 | const cases: Array<[string, Record]> = [ 9 | [`z-0`, { zIndex: 0 }], 10 | [`z-10`, { zIndex: 10 }], 11 | [`z-30`, { zIndex: 30 }], 12 | [`-z-30`, { zIndex: -30 }], 13 | [`z-100`, { zIndex: 100 }], 14 | // arbitrary 15 | [`z-194`, { zIndex: 194 }], 16 | ]; 17 | 18 | test.each(cases)(`tw\`%s\` -> %s`, (utility, expected) => { 19 | expect(tw.style(utility)).toEqual(expected); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import type { StyleIR, Style } from './types'; 2 | import defaultStyles from './styles'; 3 | 4 | export default class Cache { 5 | private ir: Map = new Map(defaultStyles); 6 | private styles: Map = new Map(); 7 | private prefixes: Map = new Map(); 8 | 9 | public constructor(customStyles?: Array<[string, StyleIR]>) { 10 | this.ir = new Map([...defaultStyles, ...(customStyles ?? [])]); 11 | } 12 | 13 | public getStyle(key: string): Style | undefined { 14 | return this.styles.get(key); 15 | } 16 | 17 | public setStyle(key: string, style: Style): void { 18 | this.styles.set(key, style); 19 | } 20 | 21 | public getIr(key: string): StyleIR | undefined { 22 | return this.ir.get(key); 23 | } 24 | 25 | public setIr(key: string, ir: StyleIR): void { 26 | this.ir.set(key, ir); 27 | } 28 | 29 | public getPrefixMatch(key: string): boolean | undefined { 30 | return this.prefixes.get(key); 31 | } 32 | 33 | public setPrefixMatch(key: string, value: boolean): void { 34 | this.prefixes.set(key, value); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/create.ts: -------------------------------------------------------------------------------- 1 | import resolveConfig from 'tailwindcss/resolveConfig'; 2 | import type { 3 | ClassInput, 4 | DependentStyle, 5 | Style, 6 | TailwindFn, 7 | RnColorScheme, 8 | OrderedStyle, 9 | StyleIR, 10 | DeviceContext, 11 | Platform, 12 | } from './types'; 13 | import type { TwConfig } from './tw-config'; 14 | import Cache from './cache'; 15 | import UtilityParser from './UtilityParser'; 16 | import { configColor, removeOpacityHelpers } from './resolve/color'; 17 | import { parseInputs } from './parse-inputs'; 18 | import { complete, warn } from './helpers'; 19 | import { getAddedUtilities } from './plugin'; 20 | 21 | export function create(customConfig: TwConfig, platform: Platform): TailwindFn { 22 | const config = resolveConfig(withContent(customConfig) as any) as TwConfig; 23 | const device: DeviceContext = {}; 24 | 25 | const pluginUtils = getAddedUtilities(config.plugins); 26 | const customStringUtils: Record = {}; 27 | const customStyleUtils = Object.entries(pluginUtils) 28 | .map(([rawUtil, style]): [string, StyleIR] => { 29 | const util = rawUtil.replace(/^\./, ``); 30 | if (typeof style === `string`) { 31 | // sacrifice functional purity to only iterate once 32 | customStringUtils[util] = style; 33 | return [util, { kind: `null` }]; 34 | } 35 | return [util, complete(style)]; 36 | }) 37 | .filter(([, ir]) => ir.kind !== `null`); 38 | 39 | patchCustomFontUtils(customConfig, customStyleUtils, config); 40 | 41 | function deriveCacheGroup(): string { 42 | return ( 43 | [ 44 | device.colorScheme === `dark` ? `dark` : false, 45 | device.windowDimensions ? `w${device.windowDimensions.width}` : false, 46 | device.windowDimensions ? `h${device.windowDimensions.height}` : false, 47 | device.fontScale ? `fs${device.fontScale}` : false, 48 | device.pixelDensity === 2 ? `retina` : false, 49 | ] 50 | .filter(Boolean) 51 | .join(`--`) || `default` 52 | ); 53 | } 54 | 55 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 56 | const tailwindFn = (strings: TemplateStringsArray, ...values: (string | number)[]) => { 57 | let str = ``; 58 | strings.forEach((string, i) => { 59 | str += string + (values[i] ?? ``); 60 | }); 61 | return style(str); 62 | }; 63 | 64 | const contextCaches: Record = {}; 65 | let cache = new Cache(); 66 | tailwindFn.memoBuster = ``; 67 | configureCache(); 68 | 69 | function configureCache(): void { 70 | const cacheGroup = deriveCacheGroup(); 71 | tailwindFn.memoBuster = `twrnc-memobuster-key--${cacheGroup}`; 72 | const existing = contextCaches[cacheGroup]; 73 | if (existing) { 74 | cache = existing; 75 | return; 76 | } 77 | const newCache = new Cache(customStyleUtils); 78 | contextCaches[cacheGroup] = newCache; 79 | // set custom string utils into cache, so they are resolvable at all breakpoints 80 | for (const [key, value] of Object.entries(customStringUtils)) { 81 | newCache.setIr(key, complete(style(value))); 82 | } 83 | cache = newCache; 84 | } 85 | 86 | function style(...inputs: ClassInput[]): Style { 87 | let resolved: Style = {}; 88 | const dependents: DependentStyle[] = []; 89 | const ordered: OrderedStyle[] = []; 90 | const [utilities, userStyle] = parseInputs(inputs); 91 | 92 | // check if we've seen this full set of classes before 93 | // if we have a cached copy, we can skip examining each utility 94 | const joined = utilities.join(` `); 95 | const cached = cache.getStyle(joined); 96 | if (cached) { 97 | return userStyle ? { ...cached, ...userStyle } : cached; 98 | } 99 | 100 | for (const utility of utilities) { 101 | let styleIr = cache.getIr(utility); 102 | if (!styleIr) { 103 | const parser = new UtilityParser(utility, config, cache, device, platform); 104 | styleIr = parser.parse(); 105 | } 106 | 107 | switch (styleIr.kind) { 108 | case `complete`: 109 | resolved = { ...resolved, ...styleIr.style }; 110 | cache.setIr(utility, styleIr); 111 | break; 112 | case `dependent`: 113 | dependents.push(styleIr); 114 | break; 115 | case `ordered`: 116 | ordered.push(styleIr); 117 | break; 118 | case `null`: 119 | cache.setIr(utility, styleIr); 120 | break; 121 | } 122 | } 123 | 124 | if (ordered.length > 0) { 125 | ordered.sort((a, b) => a.order - b.order); 126 | for (const orderedStyle of ordered) { 127 | switch (orderedStyle.styleIr.kind) { 128 | case `complete`: 129 | resolved = { ...resolved, ...orderedStyle.styleIr.style }; 130 | break; 131 | case `dependent`: 132 | dependents.push(orderedStyle.styleIr); 133 | break; 134 | } 135 | } 136 | } 137 | 138 | if (dependents.length > 0) { 139 | for (const dependent of dependents) { 140 | const error = dependent.complete(resolved); 141 | if (error) { 142 | warn(error); 143 | } 144 | } 145 | removeOpacityHelpers(resolved); 146 | } 147 | 148 | // cache the full set of classes for future re-renders 149 | // it's important we cache BEFORE merging in userStyle below 150 | if (joined !== ``) { 151 | cache.setStyle(joined, resolved); 152 | } 153 | 154 | if (userStyle) { 155 | resolved = { ...resolved, ...userStyle }; 156 | } 157 | 158 | return resolved; 159 | } 160 | 161 | function color(utils: string): string | undefined { 162 | const styleObj = style( 163 | utils 164 | .split(/\s+/g) 165 | .map((util) => util.replace(/^(bg|text|border)-/, ``)) 166 | .map((util) => `bg-${util}`) 167 | .join(` `), 168 | ); 169 | if (typeof styleObj.backgroundColor === `string`) { 170 | return styleObj.backgroundColor; 171 | } else if (config.theme?.colors) { 172 | return configColor(utils, config.theme.colors) ?? undefined; 173 | } else { 174 | return undefined; 175 | } 176 | } 177 | 178 | tailwindFn.style = style; 179 | tailwindFn.color = color; 180 | 181 | tailwindFn.prefixMatch = (...prefixes: string[]) => { 182 | const joined = prefixes.sort().join(`:`); 183 | const cached = cache.getPrefixMatch(joined); 184 | if (cached !== undefined) { 185 | return cached; 186 | } 187 | const parser = new UtilityParser(`${joined}:flex`, config, cache, device, platform); 188 | const ir = parser.parse(); 189 | const prefixMatches = ir.kind !== `null`; 190 | cache.setPrefixMatch(joined, prefixMatches); 191 | return prefixMatches; 192 | }; 193 | 194 | tailwindFn.setWindowDimensions = (newDimensions: { width: number; height: number }) => { 195 | device.windowDimensions = newDimensions; 196 | configureCache(); 197 | }; 198 | 199 | tailwindFn.setFontScale = (newFontScale: number) => { 200 | device.fontScale = newFontScale; 201 | configureCache(); 202 | }; 203 | 204 | tailwindFn.setPixelDensity = (newPixelDensity: 1 | 2) => { 205 | device.pixelDensity = newPixelDensity; 206 | configureCache(); 207 | }; 208 | 209 | tailwindFn.setColorScheme = (newColorScheme: RnColorScheme) => { 210 | device.colorScheme = newColorScheme; 211 | configureCache(); 212 | }; 213 | 214 | tailwindFn.getColorScheme = () => device.colorScheme; 215 | 216 | tailwindFn.updateDeviceContext = ( 217 | window: { width: number; height: number }, 218 | fontScale: number, 219 | pixelDensity: 1 | 2, 220 | colorScheme: RnColorScheme | 'skip', 221 | ) => { 222 | device.windowDimensions = window; 223 | device.fontScale = fontScale; 224 | device.pixelDensity = pixelDensity; 225 | if (colorScheme !== `skip`) { 226 | device.colorScheme = colorScheme; 227 | } 228 | configureCache(); 229 | }; 230 | 231 | return tailwindFn; 232 | } 233 | 234 | export default create; 235 | 236 | function withContent(config: TwConfig): TwConfig & { content: string[] } { 237 | return { 238 | ...config, 239 | // prevent warnings from tailwind about not having a `content` prop 240 | // we don't need one because we have our own jit parser which 241 | // does not rely on knowing content paths to search 242 | content: [`_no_warnings_please`], 243 | }; 244 | } 245 | 246 | // Allow override default font- style 247 | // @TODO: long-term, i'd like to think of a more generic way to allow 248 | // custom configurations not to get masked by default utilities... 249 | function patchCustomFontUtils( 250 | customConfig: TwConfig, 251 | customStyleUtils: Array<[string, StyleIR]>, 252 | config: TwConfig, 253 | ): void { 254 | if (customConfig.theme?.fontWeight || customConfig.theme?.extend?.fontWeight) { 255 | [ 256 | ...Object.entries(customConfig.theme?.fontWeight ?? {}), 257 | ...Object.entries(customConfig.theme?.extend?.fontWeight ?? {}), 258 | ].forEach(([name, value]) => { 259 | customStyleUtils.push([`font-${name}`, complete({ fontWeight: String(value) })]); 260 | }); 261 | } 262 | if (`object` === typeof config.theme?.fontFamily) { 263 | [ 264 | ...Object.entries(customConfig.theme?.fontFamily ?? {}), 265 | ...Object.entries(customConfig.theme?.extend?.fontFamily ?? {}), 266 | ].forEach(([name, value]) => { 267 | const fontFamily = Array.isArray(value) ? value[0] : value; 268 | if (fontFamily) { 269 | customStyleUtils.push([`font-${name}`, complete({ fontFamily })]); 270 | } 271 | }); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { Style, Direction, CompleteStyle, ParseContext } from './types'; 2 | import { Unit } from './types'; 3 | 4 | export function complete(style: Style): CompleteStyle { 5 | return { kind: `complete`, style }; 6 | } 7 | 8 | export function parseNumericValue( 9 | value: string, 10 | context: ParseContext = {}, 11 | ): [number, Unit] | null { 12 | const { fractions } = context; 13 | if (fractions && value.includes(`/`)) { 14 | const [numerator = ``, denominator = ``] = value.split(`/`, 2); 15 | const parsedNumerator = parseNumericValue(numerator); 16 | const parsedDenominator = parseNumericValue(denominator); 17 | if (!parsedNumerator || !parsedDenominator) { 18 | return null; 19 | } 20 | return [parsedNumerator[0] / parsedDenominator[0], parsedDenominator[1]]; 21 | } 22 | 23 | const number = parseFloat(value); 24 | if (Number.isNaN(number)) { 25 | return null; 26 | } 27 | 28 | const match = value.match(/(([a-z]{2,}|%))$/); 29 | if (!match) { 30 | return [number, Unit.none]; 31 | } 32 | 33 | switch (match?.[1]) { 34 | case `rem`: 35 | return [number, Unit.rem]; 36 | case `px`: 37 | return [number, Unit.px]; 38 | case `em`: 39 | return [number, Unit.em]; 40 | case `%`: 41 | return [number, Unit.percent]; 42 | case `vw`: 43 | return [number, Unit.vw]; 44 | case `vh`: 45 | return [number, Unit.vh]; 46 | case `deg`: 47 | return [number, Unit.deg]; 48 | case `rad`: 49 | return [number, Unit.rad]; 50 | default: 51 | return null; 52 | } 53 | } 54 | 55 | export function getCompleteStyle( 56 | prop: string, 57 | value: string | number | undefined, 58 | context: ParseContext = {}, 59 | ): CompleteStyle | null { 60 | const styleVal = parseStyleVal(value, context); 61 | return styleVal === null ? null : complete({ [prop]: styleVal }); 62 | } 63 | 64 | export function mergeStyle( 65 | prop: string, 66 | value: string | number | undefined, 67 | style: Style, 68 | ): Style { 69 | const styleVal = parseStyleVal(value); 70 | if (styleVal !== null) { 71 | style[prop] = styleVal; 72 | } 73 | return style; 74 | } 75 | 76 | export function getStyle(prop: string, value: string | number | undefined): Style | null { 77 | const styleVal = parseStyleVal(value); 78 | return styleVal === null ? null : { [prop]: styleVal }; 79 | } 80 | 81 | export function parseStyleVal( 82 | value: string | number | undefined, 83 | context: ParseContext = {}, 84 | ): null | string | number { 85 | if (value === undefined) { 86 | return null; 87 | } 88 | const parsed = parseNumericValue(String(value), context); 89 | if (parsed) { 90 | return toStyleVal(...parsed, context); 91 | } else { 92 | return null; 93 | } 94 | } 95 | 96 | export function toStyleVal( 97 | number: number, 98 | unit: Unit, 99 | context: ParseContext = {}, 100 | ): string | number | null { 101 | const { isNegative, device } = context; 102 | switch (unit) { 103 | case Unit.rem: 104 | return number * 16 * (isNegative ? -1 : 1); 105 | case Unit.px: 106 | return number * (isNegative ? -1 : 1); 107 | case Unit.percent: 108 | return `${isNegative ? `-` : ``}${number}%`; 109 | case Unit.none: 110 | return number * (isNegative ? -1 : 1); 111 | case Unit.vw: 112 | if (!device?.windowDimensions) { 113 | warn(`\`vw\` CSS unit requires configuration with \`useDeviceContext()\``); 114 | return null; 115 | } 116 | return device.windowDimensions.width * (number / 100); 117 | case Unit.vh: 118 | if (!device?.windowDimensions) { 119 | warn(`\`vh\` CSS unit requires configuration with \`useDeviceContext()\``); 120 | return null; 121 | } 122 | return device.windowDimensions.height * (number / 100); 123 | case Unit.deg: 124 | case Unit.rad: 125 | return `${number * (isNegative ? -1 : 1)}${unit}`; 126 | default: 127 | return null; 128 | } 129 | } 130 | 131 | export function toPx(value: string): number | null { 132 | const parsed = parseNumericValue(value); 133 | if (!parsed) { 134 | return null; 135 | } 136 | const [number, unit] = parsed; 137 | switch (unit) { 138 | case Unit.rem: 139 | return number * 16; 140 | case Unit.px: 141 | return number; 142 | default: 143 | return null; 144 | } 145 | } 146 | 147 | const DIR_MAP: Record = { 148 | t: `Top`, 149 | tr: `TopRight`, 150 | tl: `TopLeft`, 151 | b: `Bottom`, 152 | br: `BottomRight`, 153 | bl: `BottomLeft`, 154 | l: `Left`, 155 | r: `Right`, 156 | x: `Horizontal`, 157 | y: `Vertical`, 158 | }; 159 | 160 | export function getDirection(string?: string): Direction { 161 | return DIR_MAP[string ?? ``] || `All`; 162 | } 163 | 164 | export function parseAndConsumeDirection(utilityFragment: string): [string, Direction] { 165 | let direction: Direction = `All`; 166 | const consumed = utilityFragment.replace(/^-(t|b|r|l|tr|tl|br|bl)(-|$)/, (_, dir) => { 167 | direction = getDirection(dir); 168 | return ``; 169 | }); 170 | return [consumed, direction]; 171 | } 172 | 173 | export function parseUnconfigged( 174 | value: string, 175 | context: ParseContext = {}, 176 | ): string | number | null { 177 | if (value.includes(`/`)) { 178 | const style = unconfiggedStyleVal(value, { ...context, fractions: true }); 179 | if (style) return style; 180 | } 181 | if (value[0] === `[`) { 182 | value = value.slice(1, -1); 183 | } 184 | return unconfiggedStyleVal(value, context); 185 | } 186 | 187 | export function unconfiggedStyle( 188 | prop: string, 189 | value: string, 190 | context: ParseContext = {}, 191 | ): CompleteStyle | null { 192 | const styleVal = parseUnconfigged(value, context); 193 | if (styleVal === null) { 194 | return null; 195 | } 196 | return complete({ [prop]: styleVal }); 197 | } 198 | 199 | function unconfiggedStyleVal( 200 | value: string, 201 | context: ParseContext = {}, 202 | ): string | number | null { 203 | if (value === `px`) { 204 | return 1; 205 | } 206 | 207 | const parsed = parseNumericValue(value, context); 208 | if (!parsed) { 209 | return null; 210 | } 211 | 212 | let [number, unit] = parsed; 213 | if (context.fractions) { 214 | unit = Unit.percent; 215 | number *= 100; 216 | } 217 | 218 | // not sure if this is the right approach, but this allows arbitrary 219 | // non-bracket numbers, like top-73 and it INFERS the meaning to be 220 | // tailwind's default scale for spacing, which is 1 = 0.25rem 221 | if (unit === Unit.none) { 222 | number = number / 4; 223 | unit = Unit.rem; 224 | } 225 | 226 | return toStyleVal(number, unit, context); 227 | } 228 | 229 | function consoleWarn(...args: any[]): void { 230 | console.warn(...args); // eslint-disable-line no-console 231 | } 232 | 233 | function noopWarn(..._: any[]): void { 234 | // ¯\_(ツ)_/¯ 235 | } 236 | 237 | export const warn: (...args: any[]) => void = 238 | typeof process === `undefined` || process?.env?.JEST_WORKER_ID === undefined 239 | ? consoleWarn 240 | : noopWarn; 241 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useColorScheme, useWindowDimensions } from 'react-native'; 3 | import type { TailwindFn, RnColorScheme } from './types'; 4 | 5 | type AppOptions = { 6 | observeDeviceColorSchemeChanges: false; 7 | initialColorScheme: 'device' | 'light' | 'dark'; 8 | }; 9 | 10 | export function useDeviceContext(tw: TailwindFn, appOptions?: AppOptions): void { 11 | const deviceColorScheme = useColorScheme(); 12 | useState(() => { 13 | // (mis?)use `useState` initializer fn to initialize appColorScheme only ONCE 14 | if (appOptions) { 15 | const initial = appOptions.initialColorScheme; 16 | tw.setColorScheme(initial === `device` ? deviceColorScheme : initial); 17 | if (`withDeviceColorScheme` in appOptions) { 18 | console.error(MIGRATION_ERR); // eslint-disable-line no-console 19 | } 20 | } 21 | }); 22 | const window = useWindowDimensions(); 23 | tw.updateDeviceContext( 24 | window, 25 | window.fontScale, 26 | window.scale === 1 ? 1 : 2, 27 | appOptions ? `skip` : deviceColorScheme, 28 | ); 29 | } 30 | 31 | export function useAppColorScheme( 32 | tw: TailwindFn, 33 | ): [ 34 | colorScheme: RnColorScheme, 35 | toggleColorScheme: () => void, 36 | setColorScheme: (colorScheme: RnColorScheme) => void, 37 | ] { 38 | const [helper, setHelper] = useState(0); 39 | return [ 40 | tw.getColorScheme(), 41 | () => { 42 | tw.setColorScheme(tw.getColorScheme() === `dark` ? `light` : `dark`); 43 | setHelper(helper + 1); 44 | }, 45 | (newColorScheme) => { 46 | tw.setColorScheme(newColorScheme); 47 | setHelper(helper + 1); 48 | }, 49 | ]; 50 | } 51 | 52 | const MIGRATION_ERR = `\`withDeviceColorScheme\` has been changed to \`observeDeviceColorSchemeChanges\` in twrnc@4.0.0 -- see migration-guide.md for more details`; 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from 'react-native'; 2 | import type { TailwindFn, RnColorScheme, ClassInput, Style } from './types'; 3 | import type { TwConfig } from './tw-config'; 4 | import plugin from './plugin'; 5 | import rawCreate from './create'; 6 | 7 | // Apply default config and inject RN Platform 8 | const create = (twConfig: TwConfig = {}): TailwindFn => rawCreate(twConfig, Platform.OS); 9 | 10 | export type { TailwindFn, TwConfig, RnColorScheme, ClassInput, Style }; 11 | export { useDeviceContext, useAppColorScheme } from './hooks'; 12 | 13 | const tailwind = create(); 14 | const style = tailwind.style; 15 | 16 | export default tailwind; 17 | 18 | export { create, plugin, style }; 19 | -------------------------------------------------------------------------------- /src/parse-inputs.ts: -------------------------------------------------------------------------------- 1 | import type { ClassInput, Style } from './types'; 2 | 3 | export function parseInputs( 4 | inputs: ClassInput[], 5 | ): [classNames: string[], rnStyles: Style | null] { 6 | let classNames: string[] = []; 7 | let styles: Style | null = null; 8 | 9 | inputs.forEach((input) => { 10 | if (typeof input === `string`) { 11 | classNames = [...classNames, ...split(input)]; 12 | } else if (Array.isArray(input)) { 13 | classNames = [...classNames, ...input.flatMap(split)]; 14 | } else if (typeof input === `object` && input !== null) { 15 | for (const [key, value] of Object.entries(input)) { 16 | if (typeof value === `boolean`) { 17 | classNames = [...classNames, ...(value ? split(key) : [])]; 18 | } else if (styles) { 19 | styles[key] = value; 20 | } else { 21 | styles = { [key]: value }; 22 | } 23 | } 24 | } 25 | }); 26 | 27 | return [classNames.filter(Boolean).filter(unique), styles]; 28 | } 29 | 30 | function split(str: string): string[] { 31 | return str.trim().split(/\s+/); 32 | } 33 | 34 | function unique(className: string, index: number, classes: string[]): boolean { 35 | return classes.lastIndexOf(className) === index; 36 | } 37 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { TwConfig } from './tw-config'; 2 | import type { AddedUtilities, CreatePlugin, PluginFunction } from './types'; 3 | 4 | const plugin: CreatePlugin = (handler) => { 5 | return { handler, config: undefined }; 6 | }; 7 | 8 | export default plugin; 9 | 10 | export function getAddedUtilities(plugins: TwConfig['plugins']): AddedUtilities { 11 | return ( 12 | plugins?.reduce( 13 | (utils, plugin) => ({ ...utils, ...callPluginFunction(plugin.handler) }), 14 | {}, 15 | ) ?? {} 16 | ); 17 | } 18 | 19 | function callPluginFunction(pluginFn: PluginFunction): AddedUtilities { 20 | let added: AddedUtilities = {}; 21 | pluginFn({ 22 | addUtilities: (utilities) => { 23 | added = utilities; 24 | }, 25 | ...core, 26 | }); 27 | return added; 28 | } 29 | 30 | function notImplemented(fn: string): never { 31 | throw new Error( 32 | `tailwindcss plugin function argument object prop "${fn}" not implemented`, 33 | ); 34 | } 35 | 36 | const core = { 37 | addComponents: notImplemented, 38 | addBase: notImplemented, 39 | addVariant: notImplemented, 40 | e: notImplemented, 41 | prefix: notImplemented, 42 | theme: notImplemented, 43 | variants: notImplemented, 44 | config: notImplemented, 45 | corePlugins: notImplemented, 46 | matchUtilities: notImplemented, 47 | postcss: null, 48 | }; 49 | -------------------------------------------------------------------------------- /src/resolve/borders.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { ColorStyleType, Direction, StyleIR } from '../types'; 3 | import { 4 | parseAndConsumeDirection, 5 | complete, 6 | getCompleteStyle, 7 | unconfiggedStyle, 8 | } from '../helpers'; 9 | import { color } from './color'; 10 | 11 | export function border(value: string, theme?: TwTheme): StyleIR | null { 12 | let [rest, direction] = parseAndConsumeDirection(value); 13 | const widthUtilityMatch = rest.match(/^(-?(\d)+)?$/); 14 | if (widthUtilityMatch) { 15 | return borderWidth(rest, direction, theme?.borderWidth); 16 | } 17 | 18 | rest = rest.replace(/^-/, ``); 19 | if ([`dashed`, `solid`, `dotted`].includes(rest)) { 20 | return complete({ borderStyle: rest }); 21 | } 22 | 23 | let colorType: ColorStyleType = `border`; 24 | switch (direction) { 25 | case `Bottom`: 26 | colorType = `borderBottom`; 27 | break; 28 | case `Top`: 29 | colorType = `borderTop`; 30 | break; 31 | case `Left`: 32 | colorType = `borderLeft`; 33 | break; 34 | case `Right`: 35 | colorType = `borderRight`; 36 | break; 37 | } 38 | 39 | const colorStyle = color(colorType, rest, theme?.borderColor); 40 | if (colorStyle) { 41 | return colorStyle; 42 | } 43 | 44 | // Finally Handling Arbitrary Width Case 45 | // border-[20px] or border-[2.5rem] 46 | const prop = `border${direction === `All` ? `` : direction}Width`; 47 | rest = rest.replace(/^-/, ``); 48 | const numericValue = rest.slice(1, -1); 49 | const arbitraryWidth = unconfiggedStyle(prop, numericValue); 50 | // can't use % for border-radius in RN 51 | if (typeof arbitraryWidth?.style[prop] !== `number`) { 52 | return null; 53 | } 54 | return arbitraryWidth; 55 | } 56 | 57 | function borderWidth( 58 | value: string, 59 | direction: Direction, 60 | config?: TwTheme['borderWidth'], 61 | ): StyleIR | null { 62 | if (!config) { 63 | return null; 64 | } 65 | 66 | value = value.replace(/^-/, ``); 67 | const key = value === `` ? `DEFAULT` : value; 68 | const configValue = config[key]; 69 | if (configValue === undefined) { 70 | return null; 71 | } 72 | 73 | const prop = `border${direction === `All` ? `` : direction}Width`; 74 | return getCompleteStyle(prop, configValue); 75 | } 76 | 77 | export function borderRadius( 78 | value: string, 79 | config?: TwTheme['borderRadius'], 80 | ): StyleIR | null { 81 | if (!config) { 82 | return null; 83 | } 84 | 85 | let [rest, direction] = parseAndConsumeDirection(value); 86 | rest = rest.replace(/^-/, ``); 87 | if (rest === ``) { 88 | rest = `DEFAULT`; 89 | } 90 | 91 | const prop = `border${direction === `All` ? `` : direction}Radius`; 92 | const configValue = config[rest]; 93 | if (configValue) { 94 | return expand(getCompleteStyle(prop, configValue)); 95 | } 96 | 97 | const arbitrary = unconfiggedStyle(prop, rest); 98 | 99 | // can't use % for border-radius in RN 100 | if (typeof arbitrary?.style[prop] !== `number`) { 101 | return null; 102 | } 103 | 104 | return expand(arbitrary); 105 | } 106 | 107 | // RN only supports `borderRadius` + `border(top|bottom)(left|right)Radius` 108 | function expand(ir: StyleIR | null): StyleIR | null { 109 | if (ir?.kind !== `complete`) return ir; 110 | const top = ir.style.borderTopRadius; 111 | if (top !== undefined) { 112 | ir.style.borderTopLeftRadius = top; 113 | ir.style.borderTopRightRadius = top; 114 | delete ir.style.borderTopRadius; 115 | } 116 | const bottom = ir.style.borderBottomRadius; 117 | if (bottom !== undefined) { 118 | ir.style.borderBottomLeftRadius = bottom; 119 | ir.style.borderBottomRightRadius = bottom; 120 | delete ir.style.borderBottomRadius; 121 | } 122 | const left = ir.style.borderLeftRadius; 123 | if (left !== undefined) { 124 | ir.style.borderBottomLeftRadius = left; 125 | ir.style.borderTopLeftRadius = left; 126 | delete ir.style.borderLeftRadius; 127 | } 128 | const right = ir.style.borderRightRadius; 129 | if (right !== undefined) { 130 | ir.style.borderBottomRightRadius = right; 131 | ir.style.borderTopRightRadius = right; 132 | delete ir.style.borderRightRadius; 133 | } 134 | return ir; 135 | } 136 | -------------------------------------------------------------------------------- /src/resolve/color.ts: -------------------------------------------------------------------------------- 1 | import type { ColorStyleType, Style, StyleIR } from '../types'; 2 | import type { TwColors } from '../tw-config'; 3 | import { isObject, isString } from '../types'; 4 | import { warn } from '../helpers'; 5 | 6 | export function color( 7 | type: ColorStyleType, 8 | value: string, 9 | config?: TwColors, 10 | ): StyleIR | null { 11 | if (!config) { 12 | return null; 13 | } 14 | // support opacity shorthand: `bg-red-200/50` 15 | let shorthandOpacity: string | undefined = undefined; 16 | if (value.includes(`/`)) { 17 | [value = ``, shorthandOpacity] = value.split(`/`, 2); 18 | } 19 | 20 | let color = ``; 21 | 22 | // arbitrary hex/rgb(a)/hsl(a) support: `bg-[#eaeaea]`, `text-[rgba(1, 1, 1, 0.5)]`, `text-[hsla(1, 1%, 1%, 0.5)]` 23 | if (value.startsWith(`[#`) || value.startsWith(`[rgb`) || value.startsWith(`[hsl`)) { 24 | color = value.slice(1, -1); 25 | // arbitrary named colors: `bg-[lemonchiffon]` 26 | } else if (value.startsWith(`[`) && value.slice(1, -1).match(/^[a-z]{3,}$/)) { 27 | color = value.slice(1, -1); 28 | } else { 29 | color = configColor(value, config) ?? ``; 30 | } 31 | 32 | if (!color) { 33 | return null; 34 | } 35 | 36 | if (shorthandOpacity) { 37 | const opacity = Number(shorthandOpacity); 38 | if (!Number.isNaN(opacity)) { 39 | color = addOpacity(color, opacity / 100); 40 | return { 41 | // even though we know the bg opacity, return `dependent` to work around 42 | // subtle dark-mode ordering issue when combining shorthand & non-shorthand 43 | // @see https://github.com/jaredh159/tailwind-react-native-classnames/pull/269 44 | kind: `dependent`, 45 | complete(style) { 46 | style[STYLE_PROPS[type].color] = color; 47 | }, 48 | }; 49 | } 50 | } 51 | 52 | // return a dependent style to support merging of classes 53 | // like `bg-red-800 bg-opacity-75` 54 | return { 55 | kind: `dependent`, 56 | complete(style) { 57 | const opacityProp = STYLE_PROPS[type].opacity; 58 | const opacity = style[opacityProp]; 59 | if (typeof opacity === `number`) { 60 | color = addOpacity(color, opacity); 61 | } 62 | style[STYLE_PROPS[type].color] = color; 63 | }, 64 | }; 65 | } 66 | 67 | export function colorOpacity(type: ColorStyleType, value: string): StyleIR | null { 68 | const percentage = parseInt(value, 10); 69 | if (Number.isNaN(percentage)) { 70 | return null; 71 | } 72 | 73 | const opacity = percentage / 100; 74 | const style = { [STYLE_PROPS[type].opacity]: opacity }; 75 | return { kind: `complete`, style }; 76 | } 77 | 78 | function addOpacity(color: string, opacity: number): string { 79 | if (color.startsWith(`#`)) { 80 | color = hexToRgba(color); 81 | } else if (color.startsWith(`rgb(`) || color.startsWith(`hsl(`)) { 82 | color = color.replace(/^rgb\(/, `rgba(`).replace(/^hsl\(/, `hsla(`); 83 | if (color.includes(`,`)) { 84 | color = color.replace(/\)$/, `, 1)`); 85 | } else { 86 | color = color.replace(/\)$/, ` / 1)`); 87 | } 88 | } 89 | return color.replace(/ ?\d*\.?(\d+)\)$/, ` ${opacity})`); 90 | } 91 | 92 | export function removeOpacityHelpers(style: Style): void { 93 | for (const key in style) { 94 | if (key.startsWith(`__opacity_`)) { 95 | delete style[key]; 96 | } 97 | } 98 | } 99 | 100 | const STYLE_PROPS = { 101 | bg: { opacity: `__opacity_bg`, color: `backgroundColor` }, 102 | text: { opacity: `__opacity_text`, color: `color` }, 103 | border: { opacity: `__opacity_border`, color: `borderColor` }, 104 | borderTop: { opacity: `__opacity_border`, color: `borderTopColor` }, 105 | borderBottom: { opacity: `__opacity_border`, color: `borderBottomColor` }, 106 | borderLeft: { opacity: `__opacity_border`, color: `borderLeftColor` }, 107 | borderRight: { opacity: `__opacity_border`, color: `borderRightColor` }, 108 | shadow: { opacity: `__opacity_shadow`, color: `shadowColor` }, 109 | tint: { opacity: `__opacity_tint`, color: `tintColor` }, 110 | }; 111 | 112 | function hexToRgba(hex: string): string { 113 | const orig = hex; 114 | hex = hex.replace(MATCH_SHORT_HEX, (_, r, g, b) => r + r + g + g + b + b); 115 | const result = MATCH_FULL_HEX.exec(hex); 116 | if (!result) { 117 | warn(`invalid config hex color value: ${orig}`); 118 | return `rgba(0, 0, 0, 1)`; 119 | } 120 | 121 | const r = parseInt(result[1] ?? ``, 16); 122 | const g = parseInt(result[2] ?? ``, 16); 123 | const b = parseInt(result[3] ?? ``, 16); 124 | return `rgba(${r}, ${g}, ${b}, 1)`; 125 | } 126 | 127 | export function configColor(colorName: string, config: TwColors): string | null { 128 | const color = config[colorName]; 129 | 130 | // the color is found at the current config level 131 | if (isString(color)) { 132 | return color; 133 | } else if (isObject(color) && isString(color.DEFAULT)) { 134 | return color.DEFAULT; 135 | } 136 | 137 | // search for a matching sub-string at the current config level 138 | let [colorNameStart = ``, ...colorNameRest] = colorName.split(`-`); 139 | while (colorNameStart !== colorName) { 140 | const subConfig = config[colorNameStart]; 141 | if (isObject(subConfig)) { 142 | return configColor(colorNameRest.join(`-`), subConfig); 143 | } else if (colorNameRest.length === 0) { 144 | return null; 145 | } 146 | colorNameStart = `${colorNameStart}-${colorNameRest.shift()}`; 147 | } 148 | 149 | return null; 150 | } 151 | 152 | const MATCH_SHORT_HEX = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; 153 | const MATCH_FULL_HEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; 154 | -------------------------------------------------------------------------------- /src/resolve/flex.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { ParseContext, StyleIR } from '../types'; 3 | import { getCompleteStyle, complete, parseStyleVal, unconfiggedStyle } from '../helpers'; 4 | 5 | export function flexGrowShrink( 6 | type: 'Grow' | 'Shrink', 7 | value: string, 8 | config?: TwTheme['flexGrow'] | TwTheme['flexShrink'], 9 | ): StyleIR | null { 10 | value = value.replace(/^-/, ``); 11 | if (value[0] === `[` && value.endsWith(`]`)) { 12 | value = value.slice(1, -1); 13 | } 14 | const configKey = value === `` ? `DEFAULT` : value; 15 | const numericValue = Number(config?.[configKey] ?? value); 16 | if (!Number.isNaN(numericValue)) { 17 | return complete({ [`flex${type}`]: numericValue }); 18 | } 19 | return null; 20 | } 21 | 22 | export function flex(value: string, config?: TwTheme['flex']): StyleIR | null { 23 | value = config?.[value] || value; 24 | if ([`min-content`, `revert`, `unset`].includes(value)) { 25 | // unsupported 26 | return null; 27 | } 28 | 29 | // @see https://developer.mozilla.org/en-US/docs/Web/CSS/flex 30 | // MDN: One value, unitless number: flex-grow flex-basis is then equal to 0. 31 | if (value.match(/^\d+(\.\d+)?$/)) { 32 | return complete({ 33 | flexGrow: Number(value), 34 | flexBasis: `0%`, 35 | }); 36 | } 37 | 38 | // MDN: Two values (both integers): flex-grow | flex-basis 39 | let match = value.match(/^(\d+)\s+(\d+)$/); 40 | if (match) { 41 | return complete({ 42 | flexGrow: Number(match[1]), 43 | flexShrink: Number(match[2]), 44 | }); 45 | } 46 | 47 | // MDN: Two values: flex-grow | flex-basis 48 | match = value.match(/^(\d+)\s+([^ ]+)$/); 49 | if (match) { 50 | const flexBasis = parseStyleVal(match[2] ?? ``); 51 | if (!flexBasis) { 52 | return null; 53 | } 54 | return complete({ 55 | flexGrow: Number(match[1]), 56 | flexBasis, 57 | }); 58 | } 59 | 60 | // MDN: Three values: flex-grow | flex-shrink | flex-basis 61 | match = value.match(/^(\d+)\s+(\d+)\s+(.+)$/); 62 | if (match) { 63 | const flexBasis = parseStyleVal(match[3] ?? ``); 64 | if (!flexBasis) { 65 | return null; 66 | } 67 | return complete({ 68 | flexGrow: Number(match[1]), 69 | flexShrink: Number(match[2]), 70 | flexBasis, 71 | }); 72 | } 73 | 74 | return null; 75 | } 76 | 77 | export function flexBasis( 78 | value: string, 79 | context: ParseContext = {}, 80 | config?: TwTheme['flexBasis'], 81 | ): StyleIR | null { 82 | value = value.replace(/^-/, ``); 83 | const configValue = config?.[value]; 84 | 85 | if (configValue !== undefined) { 86 | return getCompleteStyle(`flexBasis`, configValue, context); 87 | } 88 | 89 | return unconfiggedStyle(`flexBasis`, value, context); 90 | } 91 | 92 | export function gap( 93 | value: string, 94 | context: ParseContext = {}, 95 | config?: TwTheme['gap'], 96 | ): StyleIR | null { 97 | let gapStyle = `gap`; 98 | 99 | value = value.replace(/^-(x|y)-/, (_, dir) => { 100 | if (dir === `x`) { 101 | gapStyle = `columnGap`; 102 | } 103 | if (dir === `y`) { 104 | gapStyle = `rowGap`; 105 | } 106 | return ``; 107 | }); 108 | 109 | value = value.replace(/^-/, ``); 110 | 111 | const configValue = config === null || config === void 0 ? void 0 : config[value]; 112 | if (configValue !== undefined) { 113 | return getCompleteStyle(gapStyle, configValue, context); 114 | } 115 | return unconfiggedStyle(gapStyle, value, context); 116 | } 117 | -------------------------------------------------------------------------------- /src/resolve/font-family.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { StyleIR } from '../types'; 3 | import { complete } from '../helpers'; 4 | 5 | export default function fontFamily( 6 | value: string, 7 | config?: TwTheme['fontFamily'], 8 | ): StyleIR | null { 9 | const configValue = config?.[value]; 10 | if (!configValue) { 11 | return null; 12 | } 13 | 14 | if (typeof configValue === `string`) { 15 | return complete({ fontFamily: configValue }); 16 | } 17 | 18 | const firstFamily = configValue[0]; 19 | if (!firstFamily) { 20 | return null; 21 | } 22 | 23 | return complete({ fontFamily: firstFamily }); 24 | } 25 | -------------------------------------------------------------------------------- /src/resolve/font-size.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { ParseContext, Style, StyleIR } from '../types'; 3 | import { Unit } from '../types'; 4 | import { 5 | getCompleteStyle, 6 | complete, 7 | getStyle, 8 | mergeStyle, 9 | parseNumericValue, 10 | unconfiggedStyle, 11 | } from '../helpers'; 12 | import resolveLineHeight from './line-height'; 13 | 14 | export default function fontSize( 15 | value: string, 16 | config?: TwTheme, 17 | context: ParseContext = {}, 18 | ): StyleIR | null { 19 | if (value.includes(`/`)) { 20 | const [fontSizeValue = ``, lineHeightValue = ``] = value.split(`/`, 2); 21 | const lh = resolveLineHeight(lineHeightValue, config?.lineHeight); 22 | const fs = fontSize(fontSizeValue, config, context); 23 | if (fs?.kind === `complete` && lh?.kind === `complete`) { 24 | return { 25 | kind: `complete`, 26 | style: { ...fs.style, ...lh.style }, 27 | }; 28 | } 29 | } 30 | 31 | const configValue = config?.fontSize?.[value]; 32 | if (!configValue) { 33 | return unconfiggedStyle(`fontSize`, value, context); 34 | } 35 | 36 | if (typeof configValue === `string`) { 37 | return getCompleteStyle(`fontSize`, configValue); 38 | } 39 | 40 | let style: Style = {}; 41 | const [sizePart, otherProps] = configValue; 42 | const fontSizeStyle = getStyle(`fontSize`, sizePart); 43 | if (fontSizeStyle) { 44 | style = fontSizeStyle; 45 | } 46 | 47 | if (typeof otherProps === `string`) { 48 | return complete( 49 | mergeStyle(`lineHeight`, calculateLineHeight(otherProps, style), style), 50 | ); 51 | } 52 | 53 | const { lineHeight, letterSpacing, fontWeight } = otherProps; 54 | if (lineHeight) { 55 | mergeStyle(`lineHeight`, calculateLineHeight(lineHeight, style), style); 56 | } 57 | 58 | if (letterSpacing) { 59 | mergeStyle(`letterSpacing`, letterSpacing, style); 60 | } 61 | 62 | if (fontWeight) { 63 | mergeStyle(`fontWeight`, fontWeight, style); 64 | } 65 | 66 | return complete(style); 67 | } 68 | 69 | // calculates line-height for relative units 70 | function calculateLineHeight(lineHeight: string, style: Style): number | string { 71 | const parsed = parseNumericValue(lineHeight); 72 | if (parsed) { 73 | const [number, unit] = parsed; 74 | if ((unit === Unit.none || unit === Unit.em) && typeof style.fontSize === `number`) { 75 | return style.fontSize * number; 76 | } 77 | } 78 | return lineHeight; 79 | } 80 | -------------------------------------------------------------------------------- /src/resolve/inset.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { StyleIR } from '../types'; 3 | import { complete, parseStyleVal, parseUnconfigged } from '../helpers'; 4 | 5 | type Inset = 'bottom' | 'top' | 'left' | 'right' | 'inset'; 6 | type InsetDir = null | 'x' | 'y'; 7 | 8 | export function inset( 9 | type: Inset, 10 | value: string, 11 | isNegative: boolean, 12 | config?: TwTheme['inset'], 13 | ): StyleIR | null { 14 | let insetDir: InsetDir = null; 15 | if (type === `inset`) { 16 | value = value.replace(/^(x|y)-/, (_, dir) => { 17 | insetDir = dir === `x` ? `x` : `y`; 18 | return ``; 19 | }); 20 | } 21 | 22 | if (value === `auto`) { 23 | return insetStyle(type, insetDir, value); 24 | } 25 | 26 | const configValue = config?.[value]; 27 | if (configValue) { 28 | const styleVal = parseStyleVal(configValue, { isNegative }); 29 | if (styleVal !== null) { 30 | return insetStyle(type, insetDir, styleVal); 31 | } 32 | } 33 | 34 | const unconfigged = parseUnconfigged(value, { isNegative }); 35 | if (unconfigged !== null) { 36 | return insetStyle(type, insetDir, unconfigged); 37 | } 38 | 39 | return null; 40 | } 41 | 42 | function insetStyle(type: Inset, dir: InsetDir, styleVal: string | number): StyleIR { 43 | if (type !== `inset`) { 44 | return complete({ [type]: styleVal }); 45 | } 46 | 47 | switch (dir) { 48 | case null: 49 | return complete({ 50 | top: styleVal, 51 | left: styleVal, 52 | right: styleVal, 53 | bottom: styleVal, 54 | }); 55 | case `y`: 56 | return complete({ 57 | top: styleVal, 58 | bottom: styleVal, 59 | }); 60 | case `x`: 61 | return complete({ 62 | left: styleVal, 63 | right: styleVal, 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/resolve/letter-spacing.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { DependentStyle, StyleIR } from '../types'; 3 | import { Unit } from '../types'; 4 | import { 5 | parseNumericValue, 6 | complete, 7 | toStyleVal, 8 | unconfiggedStyle, 9 | warn, 10 | } from '../helpers'; 11 | 12 | export function letterSpacing( 13 | value: string, 14 | isNegative: boolean, 15 | config?: TwTheme['letterSpacing'], 16 | ): StyleIR | null { 17 | const configValue = config?.[value]; 18 | if (configValue) { 19 | const parsed = parseNumericValue(configValue, { isNegative }); 20 | if (!parsed) { 21 | return null; 22 | } 23 | 24 | const [number, unit] = parsed; 25 | if (unit === Unit.em) { 26 | return relativeLetterSpacing(number); 27 | } 28 | 29 | // @TODO, if we get a percentage based config value, theoretically we could 30 | // make a font-size dependent style as well, wait for someone to raise an issue 31 | if (unit === Unit.percent) { 32 | warn( 33 | `percentage-based letter-spacing configuration currently unsupported, switch to \`em\`s, or open an issue if you'd like to see support added.`, 34 | ); 35 | return null; 36 | } 37 | 38 | const styleVal = toStyleVal(number, unit, { isNegative }); 39 | if (styleVal !== null) { 40 | return complete({ letterSpacing: styleVal }); 41 | } 42 | 43 | return null; 44 | } 45 | return unconfiggedStyle(`letterSpacing`, value, { isNegative }); 46 | } 47 | 48 | function relativeLetterSpacing(ems: number): DependentStyle { 49 | return { 50 | kind: `dependent`, 51 | complete(style) { 52 | const fontSize = style.fontSize; 53 | if (typeof fontSize !== `number` || Number.isNaN(fontSize)) { 54 | return `tracking-X relative letter spacing classes require font-size to be set`; 55 | } 56 | style.letterSpacing = Math.round((ems * fontSize + Number.EPSILON) * 100) / 100; 57 | }, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/resolve/line-height.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { StyleIR } from '../types'; 3 | import { Unit } from '../types'; 4 | import { parseNumericValue, complete, toStyleVal } from '../helpers'; 5 | 6 | export default function lineHeight( 7 | value: string, 8 | config?: TwTheme['lineHeight'], 9 | ): StyleIR | null { 10 | const parseValue = 11 | config?.[value] ?? (value.startsWith(`[`) ? value.slice(1, -1) : value); 12 | 13 | const parsed = parseNumericValue(parseValue); 14 | if (!parsed) { 15 | return null; 16 | } 17 | 18 | const [number, unit] = parsed; 19 | if (unit === Unit.none) { 20 | // we have a relative line-height like `2` for `leading-loose` 21 | return { 22 | kind: `dependent`, 23 | complete(style) { 24 | if (typeof style.fontSize !== `number`) { 25 | return `relative line-height utilities require that font-size be set`; 26 | } 27 | style.lineHeight = style.fontSize * number; 28 | }, 29 | }; 30 | } 31 | 32 | const styleVal = toStyleVal(number, unit); 33 | return styleVal !== null ? complete({ lineHeight: styleVal }) : null; 34 | } 35 | -------------------------------------------------------------------------------- /src/resolve/opacity.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { StyleIR } from '../types'; 3 | import { parseNumericValue, complete } from '../helpers'; 4 | 5 | export function opacity(value: string, config?: TwTheme['opacity']): StyleIR | null { 6 | const configValue = config?.[value]; 7 | if (configValue) { 8 | const parsedConfig = parseNumericValue(String(configValue)); 9 | if (parsedConfig) { 10 | return complete({ opacity: parsedConfig[0] }); 11 | } 12 | } 13 | const parsedArbitrary = parseNumericValue(value); 14 | if (parsedArbitrary) { 15 | return complete({ opacity: parsedArbitrary[0] / 100 }); 16 | } 17 | 18 | return null; 19 | } 20 | -------------------------------------------------------------------------------- /src/resolve/shadow.ts: -------------------------------------------------------------------------------- 1 | import type { StyleIR } from '../types'; 2 | import { parseUnconfigged } from '../helpers'; 3 | 4 | export function shadowOpacity(value: string): StyleIR | null { 5 | const percentage = parseInt(value, 10); 6 | if (Number.isNaN(percentage)) { 7 | return null; 8 | } 9 | 10 | return { 11 | kind: `complete`, 12 | style: { shadowOpacity: percentage / 100 }, 13 | }; 14 | } 15 | 16 | export function shadowOffset(value: string): StyleIR | null { 17 | if (value.includes(`/`)) { 18 | const [widthStr = ``, heightStr = ``] = value.split(`/`, 2); 19 | const width = offsetValue(widthStr); 20 | const height = offsetValue(heightStr); 21 | if (width === null || height === null) { 22 | return null; 23 | } 24 | 25 | return { 26 | kind: `complete`, 27 | style: { 28 | shadowOffset: { 29 | width, 30 | height, 31 | }, 32 | }, 33 | }; 34 | } 35 | 36 | const number = offsetValue(value); 37 | if (number === null) { 38 | return null; 39 | } 40 | 41 | return { 42 | kind: `complete`, 43 | style: { 44 | shadowOffset: { 45 | width: number, 46 | height: number, 47 | }, 48 | }, 49 | }; 50 | } 51 | 52 | function offsetValue(value: string): number | null { 53 | const parsed = parseUnconfigged(value); 54 | return typeof parsed === `number` ? parsed : null; 55 | } 56 | -------------------------------------------------------------------------------- /src/resolve/spacing.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { Direction, ParseContext, StyleIR } from '../types'; 3 | import { Unit } from '../types'; 4 | import { parseNumericValue, parseUnconfigged, toStyleVal } from '../helpers'; 5 | 6 | export default function spacing( 7 | type: 'margin' | 'padding', 8 | direction: Direction, 9 | value: string, 10 | context: ParseContext, 11 | config?: TwTheme['margin'] | TwTheme['padding'], 12 | ): StyleIR | null { 13 | let numericValue = ``; 14 | if (value[0] === `[`) { 15 | numericValue = value.slice(1, -1); 16 | } else { 17 | const configValue = config?.[value]; 18 | 19 | if (!configValue) { 20 | const unconfigged = parseUnconfigged(value); 21 | if (unconfigged && typeof unconfigged === `number`) { 22 | return spacingStyle(unconfigged, Unit.px, direction, type, context); 23 | } 24 | return null; 25 | } else { 26 | numericValue = configValue; 27 | } 28 | } 29 | 30 | if (numericValue === `auto`) { 31 | return expand(direction, type, `auto`); 32 | } 33 | 34 | const parsed = parseNumericValue(numericValue); 35 | if (!parsed) { 36 | return null; 37 | } 38 | 39 | const [number, unit] = parsed; 40 | 41 | return spacingStyle(number, unit, direction, type, context); 42 | } 43 | 44 | function spacingStyle( 45 | number: number, 46 | unit: Unit, 47 | direction: Direction, 48 | type: 'margin' | 'padding', 49 | context: ParseContext, 50 | ): StyleIR | null { 51 | const pixels = toStyleVal(number, unit, context); 52 | if (pixels === null) { 53 | return null; 54 | } 55 | return expand(direction, type, pixels); 56 | } 57 | 58 | function expand( 59 | direction: Direction, 60 | type: 'margin' | 'padding', 61 | value: number | string, 62 | ): StyleIR | null { 63 | switch (direction) { 64 | case `All`: 65 | return { 66 | kind: `complete`, 67 | style: { 68 | [`${type}Top`]: value, 69 | [`${type}Right`]: value, 70 | [`${type}Bottom`]: value, 71 | [`${type}Left`]: value, 72 | }, 73 | }; 74 | case `Bottom`: 75 | case `Top`: 76 | case `Left`: 77 | case `Right`: 78 | return { 79 | kind: `complete`, 80 | style: { 81 | [`${type}${direction}`]: value, 82 | }, 83 | }; 84 | case `Vertical`: 85 | return { 86 | kind: `complete`, 87 | style: { 88 | [`${type}Top`]: value, 89 | [`${type}Bottom`]: value, 90 | }, 91 | }; 92 | case `Horizontal`: 93 | return { 94 | kind: `complete`, 95 | style: { 96 | [`${type}Left`]: value, 97 | [`${type}Right`]: value, 98 | }, 99 | }; 100 | default: 101 | return null; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/resolve/transform.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { DependentStyle, ParseContext, Style, StyleIR } from '../types'; 3 | import { isString, Unit } from '../types'; 4 | import { 5 | parseNumericValue, 6 | parseStyleVal, 7 | parseUnconfigged, 8 | toStyleVal, 9 | } from '../helpers'; 10 | 11 | type Axis = `x` | `y` | `z` | ``; 12 | type Property = `scale` | `rotate` | `skew` | `translate`; 13 | 14 | export function scale( 15 | value: string, 16 | context: ParseContext = {}, 17 | config?: TwTheme['scale'], 18 | ): StyleIR | null { 19 | let scaleAxis: Axis = ``; 20 | 21 | value = value.replace(/^(x|y)-/, (_, axis) => { 22 | scaleAxis = axis.toUpperCase(); 23 | return ``; 24 | }); 25 | 26 | let styleVal: string | number | null = null; 27 | 28 | if (config?.[value]) { 29 | styleVal = parseStyleVal(config[value], context); 30 | } else if (isArbitraryValue(value)) { 31 | // arbitrary value should use the value as is 32 | // e.g `scale-[1.5]` should be 1.5 33 | const parsed = parseNumericValue(value.slice(1, -1)); 34 | styleVal = parsed ? parsed[0] : null; 35 | } else { 36 | // unconfigged value should divide value by 100 37 | // e.g `scale-99` should be 0.99 38 | const parsed = parseNumericValue(value); 39 | styleVal = parsed ? parsed[0] / 100 : null; 40 | } 41 | 42 | return styleVal === null ? null : createStyle(styleVal, `scale`, scaleAxis); 43 | } 44 | 45 | export function rotate( 46 | value: string, 47 | context: ParseContext = {}, 48 | config?: TwTheme['rotate'], 49 | ): StyleIR | null { 50 | let rotateAxis: Axis = ``; 51 | 52 | value = value.replace(/^(x|y|z)-/, (_, axis) => { 53 | rotateAxis = axis.toUpperCase(); 54 | return ``; 55 | }); 56 | 57 | let styleVal: string | number | null = null; 58 | 59 | if (config?.[value]) { 60 | styleVal = parseStyleVal(config[value], context); 61 | } else if (isArbitraryValue(value)) { 62 | styleVal = parseUnconfigged(value, context); 63 | } else { 64 | // unconfigged value should should be converted to degrees 65 | // e.g `rotate-99` should be `99deg` 66 | const parsed = parseNumericValue(value); 67 | styleVal = parsed ? toStyleVal(parsed[0], Unit.deg, context) : null; 68 | } 69 | 70 | return styleVal === null ? null : createStyle(styleVal, `rotate`, rotateAxis); 71 | } 72 | 73 | export function skew( 74 | value: string, 75 | context: ParseContext = {}, 76 | config?: TwTheme['skew'], 77 | ): StyleIR | null { 78 | let skewAxis: Axis = ``; 79 | 80 | value = value.replace(/^(x|y)-/, (_, axis) => { 81 | skewAxis = axis.toUpperCase(); 82 | return ``; 83 | }); 84 | 85 | if (skewAxis === ``) { 86 | return null; 87 | } 88 | 89 | let styleVal: string | number | null = null; 90 | 91 | if (config?.[value]) { 92 | styleVal = parseStyleVal(config[value], context); 93 | } else if (isArbitraryValue(value)) { 94 | styleVal = parseUnconfigged(value, context); 95 | } else { 96 | // unconfigged value should should be converted to degrees 97 | // e.g `skew-x-99` should be `99deg` 98 | const parsed = parseNumericValue(value); 99 | styleVal = parsed ? toStyleVal(parsed[0], Unit.deg, context) : null; 100 | } 101 | 102 | return styleVal === null ? null : createStyle(styleVal, `skew`, skewAxis); 103 | } 104 | 105 | export function translate( 106 | value: string, 107 | context: ParseContext = {}, 108 | config?: TwTheme['translate'], 109 | ): StyleIR | null { 110 | let translateAxis: Axis = ``; 111 | 112 | value = value.replace(/^(x|y)-/, (_, axis) => { 113 | translateAxis = axis.toUpperCase(); 114 | return ``; 115 | }); 116 | 117 | if (translateAxis === ``) { 118 | return null; 119 | } 120 | 121 | const configValue = config?.[value]; 122 | 123 | const styleVal = configValue 124 | ? parseStyleVal(configValue, context) 125 | : parseUnconfigged(value, context); 126 | 127 | // support for percentage values in translate was only added in RN 0.75 128 | // source: https://reactnative.dev/blog/2024/08/12/release-0.75#percentage-values-in-translation 129 | // since the support of this package starts at RN 0.62.2 130 | // we need to filter out percentages which are non-numeric values 131 | return styleVal === null || isString(styleVal) 132 | ? null 133 | : createStyle(styleVal, `translate`, translateAxis); 134 | } 135 | 136 | export function transformNone(): StyleIR { 137 | return { 138 | kind: `dependent`, 139 | complete(style) { 140 | style.transform = []; 141 | }, 142 | }; 143 | } 144 | 145 | function createStyle( 146 | styleVal: string | number, 147 | property: Property, 148 | axis: Axis, 149 | ): DependentStyle { 150 | return { 151 | kind: `dependent`, 152 | complete(style) { 153 | let transform = (style.transform as Style[]) ?? []; 154 | const key = `${property}${axis}`; 155 | 156 | if (transform.length > 0) { 157 | transform = transform.filter((transformItem) => transformItem[key] === undefined); 158 | } 159 | 160 | transform.push({ 161 | [key]: styleVal, 162 | }); 163 | 164 | style.transform = transform; 165 | }, 166 | }; 167 | } 168 | 169 | function isArbitraryValue(value: string): boolean { 170 | return value.startsWith(`[`) && value.endsWith(`]`); 171 | } 172 | -------------------------------------------------------------------------------- /src/resolve/width-height.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from '../tw-config'; 2 | import type { ParseContext, StyleIR } from '../types'; 3 | import { getCompleteStyle, complete, parseStyleVal, unconfiggedStyle } from '../helpers'; 4 | 5 | export function widthHeight( 6 | type: 'width' | 'height', 7 | value: string, 8 | context: ParseContext = {}, 9 | config?: TwTheme['width'] | TwTheme['height'], 10 | ): StyleIR | null { 11 | const configValue = config?.[value]; 12 | if (configValue !== undefined) { 13 | return getCompleteStyle(type, configValue, context); 14 | } 15 | 16 | return unconfiggedStyle(type, value, context); 17 | } 18 | 19 | export function size( 20 | value: string, 21 | context: ParseContext = {}, 22 | theme?: TwTheme, 23 | ): StyleIR | null { 24 | const width = widthHeight(`width`, value, context, theme?.width); 25 | const height = widthHeight(`height`, value, context, theme?.height); 26 | if (width?.kind !== `complete` || height?.kind !== `complete`) { 27 | return null; 28 | } 29 | return complete({ ...width.style, ...height.style }); 30 | } 31 | 32 | export function minMaxWidthHeight( 33 | type: 'minWidth' | 'minHeight' | 'maxWidth' | 'maxHeight', 34 | value: string, 35 | context: ParseContext = {}, 36 | config?: Record, 37 | ): StyleIR | null { 38 | const styleVal = parseStyleVal(config?.[value], context); 39 | if (styleVal) { 40 | return complete({ [type]: styleVal }); 41 | } 42 | 43 | if (value === `screen`) { 44 | value = type.includes(`Width`) ? `100vw` : `100vh`; 45 | } 46 | 47 | return unconfiggedStyle(type, value, context); 48 | } 49 | -------------------------------------------------------------------------------- /src/screens.ts: -------------------------------------------------------------------------------- 1 | import type { TwTheme } from './tw-config'; 2 | import { toPx, warn } from './helpers'; 3 | 4 | type Screens = Record; 5 | 6 | export default function screens(input?: TwTheme['screens']): Screens { 7 | if (!input) { 8 | return {}; 9 | } 10 | 11 | const screenData = Object.entries(input).reduce((acc, [screen, value]) => { 12 | const data: [number, number, number] = [0, Infinity, 0]; 13 | const values = typeof value === `string` ? { min: value } : value; 14 | const minPx = values.min ? toPx(values.min) : 0; 15 | if (minPx === null) { 16 | warn(`invalid screen config value: ${screen}->min: ${values.min}`); 17 | } else { 18 | data[0] = minPx; 19 | } 20 | const maxPx = values.max ? toPx(values.max) : Infinity; 21 | if (maxPx === null) { 22 | warn(`invalid screen config value: ${screen}->max: ${values.max}`); 23 | } else { 24 | data[1] = maxPx; 25 | } 26 | acc[screen] = data; 27 | return acc; 28 | }, {}); 29 | 30 | const values = Object.values(screenData); 31 | values.sort((a, b) => { 32 | const [minA, maxA] = a; 33 | const [minB, maxB] = b; 34 | if (maxA === Infinity || maxB === Infinity) { 35 | return minA - minB; 36 | } 37 | return maxA - maxB; 38 | }); 39 | 40 | let order = 0; 41 | values.forEach((value) => (value[2] = order++)); 42 | 43 | return screenData; 44 | } 45 | -------------------------------------------------------------------------------- /src/styles.ts: -------------------------------------------------------------------------------- 1 | import type { StyleIR, DependentStyle } from './types'; 2 | import { complete } from './helpers'; 3 | 4 | const defaultStyles: Array<[string, StyleIR]> = [ 5 | [`aspect-square`, complete({ aspectRatio: 1 })], 6 | [`aspect-video`, complete({ aspectRatio: 16 / 9 })], 7 | [`items-center`, complete({ alignItems: `center` })], 8 | [`items-start`, complete({ alignItems: `flex-start` })], 9 | [`items-end`, complete({ alignItems: `flex-end` })], 10 | [`items-baseline`, complete({ alignItems: `baseline` })], 11 | [`items-stretch`, complete({ alignItems: `stretch` })], 12 | [`justify-start`, complete({ justifyContent: `flex-start` })], 13 | [`justify-end`, complete({ justifyContent: `flex-end` })], 14 | [`justify-center`, complete({ justifyContent: `center` })], 15 | [`justify-between`, complete({ justifyContent: `space-between` })], 16 | [`justify-around`, complete({ justifyContent: `space-around` })], 17 | [`justify-evenly`, complete({ justifyContent: `space-evenly` })], 18 | [`content-start`, complete({ alignContent: `flex-start` })], 19 | [`content-end`, complete({ alignContent: `flex-end` })], 20 | [`content-between`, complete({ alignContent: `space-between` })], 21 | [`content-around`, complete({ alignContent: `space-around` })], 22 | [`content-stretch`, complete({ alignContent: `stretch` })], 23 | [`content-center`, complete({ alignContent: `center` })], 24 | [`self-auto`, complete({ alignSelf: `auto` })], 25 | [`self-start`, complete({ alignSelf: `flex-start` })], 26 | [`self-end`, complete({ alignSelf: `flex-end` })], 27 | [`self-center`, complete({ alignSelf: `center` })], 28 | [`self-stretch`, complete({ alignSelf: `stretch` })], 29 | [`self-baseline`, complete({ alignSelf: `baseline` })], 30 | 31 | [`direction-inherit`, complete({ direction: `inherit` })], 32 | [`direction-ltr`, complete({ direction: `ltr` })], 33 | [`direction-rtl`, complete({ direction: `rtl` })], 34 | 35 | [`hidden`, complete({ display: `none` })], 36 | [`flex`, complete({ display: `flex` })], 37 | 38 | [`flex-row`, complete({ flexDirection: `row` })], 39 | [`flex-row-reverse`, complete({ flexDirection: `row-reverse` })], 40 | [`flex-col`, complete({ flexDirection: `column` })], 41 | [`flex-col-reverse`, complete({ flexDirection: `column-reverse` })], 42 | [`flex-wrap`, complete({ flexWrap: `wrap` })], 43 | [`flex-wrap-reverse`, complete({ flexWrap: `wrap-reverse` })], 44 | [`flex-nowrap`, complete({ flexWrap: `nowrap` })], 45 | 46 | [`flex-auto`, complete({ flexGrow: 1, flexShrink: 1, flexBasis: `auto` })], 47 | [`flex-initial`, complete({ flexGrow: 0, flexShrink: 1, flexBasis: `auto` })], 48 | [`flex-none`, complete({ flexGrow: 0, flexShrink: 0, flexBasis: `auto` })], 49 | 50 | [`overflow-hidden`, complete({ overflow: `hidden` })], 51 | [`overflow-visible`, complete({ overflow: `visible` })], 52 | [`overflow-scroll`, complete({ overflow: `scroll` })], 53 | 54 | [`absolute`, complete({ position: `absolute` })], 55 | [`relative`, complete({ position: `relative` })], 56 | 57 | [`italic`, complete({ fontStyle: `italic` })], 58 | [`not-italic`, complete({ fontStyle: `normal` })], 59 | 60 | [`oldstyle-nums`, fontVariant(`oldstyle-nums`)], 61 | [`small-caps`, fontVariant(`small-caps`)], 62 | [`lining-nums`, fontVariant(`lining-nums`)], 63 | [`tabular-nums`, fontVariant(`tabular-nums`)], 64 | [`proportional-nums`, fontVariant(`proportional-nums`)], 65 | 66 | [`font-thin`, complete({ fontWeight: `100` })], 67 | [`font-100`, complete({ fontWeight: `100` })], 68 | [`font-extralight`, complete({ fontWeight: `200` })], 69 | [`font-200`, complete({ fontWeight: `200` })], 70 | [`font-light`, complete({ fontWeight: `300` })], 71 | [`font-300`, complete({ fontWeight: `300` })], 72 | [`font-normal`, complete({ fontWeight: `normal` })], 73 | [`font-400`, complete({ fontWeight: `400` })], 74 | [`font-medium`, complete({ fontWeight: `500` })], 75 | [`font-500`, complete({ fontWeight: `500` })], 76 | [`font-semibold`, complete({ fontWeight: `600` })], 77 | [`font-600`, complete({ fontWeight: `600` })], 78 | [`font-bold`, complete({ fontWeight: `bold` })], 79 | [`font-700`, complete({ fontWeight: `700` })], 80 | [`font-extrabold`, complete({ fontWeight: `800` })], 81 | [`font-800`, complete({ fontWeight: `800` })], 82 | [`font-black`, complete({ fontWeight: `900` })], 83 | [`font-900`, complete({ fontWeight: `900` })], 84 | 85 | [`include-font-padding`, complete({ includeFontPadding: true })], 86 | [`remove-font-padding`, complete({ includeFontPadding: false })], 87 | 88 | // not sure if RN supports `max-width: none;`, but this should be equivalent 89 | [`max-w-none`, complete({ maxWidth: `99999%` })], 90 | 91 | [`text-left`, complete({ textAlign: `left` })], 92 | [`text-center`, complete({ textAlign: `center` })], 93 | [`text-right`, complete({ textAlign: `right` })], 94 | [`text-justify`, complete({ textAlign: `justify` })], 95 | [`text-auto`, complete({ textAlign: `auto` })], // RN only 96 | 97 | [`underline`, complete({ textDecorationLine: `underline` })], 98 | [`line-through`, complete({ textDecorationLine: `line-through` })], 99 | [`no-underline`, complete({ textDecorationLine: `none` })], 100 | 101 | [`uppercase`, complete({ textTransform: `uppercase` })], 102 | [`lowercase`, complete({ textTransform: `lowercase` })], 103 | [`capitalize`, complete({ textTransform: `capitalize` })], 104 | [`normal-case`, complete({ textTransform: `none` })], 105 | 106 | [`w-auto`, complete({ width: `auto` })], 107 | [`h-auto`, complete({ height: `auto` })], 108 | 109 | [`basis-auto`, complete({ flexBasis: `auto` })], 110 | [`flex-basis-auto`, complete({ flexBasis: `auto` })], 111 | 112 | [`align-auto`, complete({ verticalAlign: `auto` })], 113 | [`align-top`, complete({ verticalAlign: `top` })], 114 | [`align-bottom`, complete({ verticalAlign: `bottom` })], 115 | [`align-middle`, complete({ verticalAlign: `middle` })], 116 | 117 | // default box-shadow implementations 118 | [ 119 | `shadow-sm`, 120 | complete({ 121 | shadowOffset: { width: 1, height: 1 }, 122 | shadowColor: `#000`, 123 | shadowRadius: 1, 124 | shadowOpacity: 0.025, 125 | elevation: 1, 126 | }), 127 | ], 128 | [ 129 | `shadow`, 130 | complete({ 131 | shadowOffset: { width: 1, height: 1 }, 132 | shadowColor: `#000`, 133 | shadowRadius: 1, 134 | shadowOpacity: 0.075, 135 | elevation: 2, 136 | }), 137 | ], 138 | [ 139 | `shadow-md`, 140 | complete({ 141 | shadowOffset: { width: 1, height: 1 }, 142 | shadowColor: `#000`, 143 | shadowRadius: 3, 144 | shadowOpacity: 0.125, 145 | elevation: 3, 146 | }), 147 | ], 148 | [ 149 | `shadow-lg`, 150 | complete({ 151 | shadowOffset: { width: 1, height: 1 }, 152 | shadowColor: `#000`, 153 | shadowOpacity: 0.15, 154 | shadowRadius: 8, 155 | elevation: 8, 156 | }), 157 | ], 158 | [ 159 | `shadow-xl`, 160 | complete({ 161 | shadowOffset: { width: 1, height: 1 }, 162 | shadowColor: `#000`, 163 | shadowOpacity: 0.19, 164 | shadowRadius: 20, 165 | elevation: 12, 166 | }), 167 | ], 168 | [ 169 | `shadow-2xl`, 170 | complete({ 171 | shadowOffset: { width: 1, height: 1 }, 172 | shadowColor: `#000`, 173 | shadowOpacity: 0.25, 174 | shadowRadius: 30, 175 | elevation: 16, 176 | }), 177 | ], 178 | [ 179 | `shadow-none`, 180 | complete({ 181 | shadowOffset: { width: 0, height: 0 }, 182 | shadowColor: `#000`, 183 | shadowRadius: 0, 184 | shadowOpacity: 0, 185 | elevation: 0, 186 | }), 187 | ], 188 | ]; 189 | 190 | export default defaultStyles; 191 | 192 | function fontVariant(type: string): DependentStyle { 193 | return { 194 | kind: `dependent`, 195 | complete(style) { 196 | if (!style.fontVariant || !Array.isArray(style.fontVariant)) { 197 | style.fontVariant = []; 198 | } 199 | (style.fontVariant as string[]).push(type); 200 | }, 201 | }; 202 | } 203 | -------------------------------------------------------------------------------- /src/tw-config.ts: -------------------------------------------------------------------------------- 1 | import type { PluginFunction } from './types'; 2 | 3 | type TwFontSize = 4 | | string 5 | | [string, string] 6 | | [string, { lineHeight?: string; letterSpacing?: string; fontWeight?: string }]; 7 | 8 | type TwScreen = string | { max?: string; min?: string }; 9 | 10 | // eg: { black: #000, gray: { 100: #eaeaea } } 11 | export type TwColors = { 12 | [key: string]: V | TwColors; 13 | }; 14 | 15 | export interface TwTheme { 16 | fontSize?: Record; 17 | lineHeight?: Record; 18 | spacing?: Record; 19 | padding?: Record; 20 | margin?: Record; 21 | inset?: Record; 22 | height?: Record; 23 | width?: Record; 24 | maxWidth?: Record; 25 | maxHeight?: Record; 26 | minWidth?: Record; 27 | minHeight?: Record; 28 | letterSpacing?: Record; 29 | borderWidth?: Record; 30 | borderRadius?: Record; 31 | screens?: Record; 32 | opacity?: Record; 33 | flex?: Record; 34 | flexBasis?: Record; 35 | flexGrow?: Record; 36 | flexShrink?: Record; 37 | gap?: Record; 38 | fontWeight?: Record; 39 | fontFamily?: Record; 40 | zIndex?: Record; 41 | colors?: TwColors; 42 | backgroundColor?: TwColors; 43 | borderColor?: TwColors; 44 | textColor?: TwColors; 45 | scale?: Record; 46 | rotate?: Record; 47 | skew?: Record; 48 | translate?: Record; 49 | extend?: Omit; 50 | } 51 | 52 | export interface TwConfig { 53 | theme?: TwTheme; 54 | plugins?: Array<{ handler: PluginFunction }>; 55 | } 56 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ViewStyle, TextStyle, ImageStyle } from 'react-native'; 2 | 3 | export interface TailwindFn { 4 | (strings: TemplateStringsArray, ...values: (string | number)[]): Style; 5 | style: (...inputs: ClassInput[]) => Style; 6 | color: (color: string) => string | undefined; 7 | prefixMatch: (...prefixes: string[]) => boolean; 8 | memoBuster: string; 9 | 10 | // NB: @see https://www.typescriptlang.org/tsconfig#stripInternal 11 | 12 | /** 13 | * @internal 14 | */ 15 | setWindowDimensions: (dimensions: { width: number; height: number }) => unknown; 16 | /** 17 | * @internal 18 | */ 19 | setFontScale: (fontScale: number) => unknown; 20 | /** 21 | * @internal 22 | */ 23 | setPixelDensity: (pixelDensity: 1 | 2) => unknown; 24 | /** 25 | * @internal 26 | */ 27 | setColorScheme: (colorScheme: RnColorScheme) => unknown; 28 | /** 29 | * @internal 30 | */ 31 | getColorScheme: () => RnColorScheme; 32 | /** 33 | * @internal 34 | */ 35 | updateDeviceContext: ( 36 | dimensions: { width: number; height: number }, 37 | fontScale: number, 38 | pixelDensity: 1 | 2, 39 | colorScheme: RnColorScheme | 'skip', 40 | ) => unknown; 41 | } 42 | 43 | export type ClassInput = 44 | | string 45 | | string[] 46 | | boolean 47 | | null 48 | | undefined 49 | | { [k: string]: boolean | string | number } 50 | | ViewStyle 51 | | TextStyle 52 | | ImageStyle; 53 | 54 | export const PLATFORMS = [`ios`, `android`, `windows`, `macos`, `web`] as const; 55 | export type Platform = (typeof PLATFORMS)[number]; 56 | 57 | export function isPlatform(x: string): x is Platform { 58 | return PLATFORMS.includes(x as Platform); 59 | } 60 | 61 | export const ORIENTATIONS = [`portrait`, `landscape`]; 62 | export type Orientation = 'portrait' | 'landscape'; 63 | 64 | export function isOrientation(x: string): x is Orientation { 65 | return ORIENTATIONS.includes(x as Orientation); 66 | } 67 | 68 | export type RnColorScheme = 'light' | 'dark' | null | undefined; 69 | 70 | export interface DeviceContext { 71 | windowDimensions?: { 72 | width: number; 73 | height: number; 74 | }; 75 | colorScheme?: 'light' | 'dark' | null; 76 | fontScale?: number; 77 | pixelDensity?: 1 | 2; 78 | } 79 | 80 | export interface ParseContext { 81 | isNegative?: boolean; 82 | fractions?: boolean; 83 | device?: DeviceContext; 84 | } 85 | 86 | export type ColorStyleType = 87 | | 'bg' 88 | | 'text' 89 | | 'border' 90 | | 'borderTop' 91 | | 'borderLeft' 92 | | 'borderRight' 93 | | 'borderBottom' 94 | | 'shadow' 95 | | 'tint'; 96 | 97 | export type Direction = 98 | | 'All' 99 | | 'Horizontal' 100 | | 'Vertical' 101 | | 'Left' 102 | | 'Right' 103 | | 'Top' 104 | | 'TopLeft' 105 | | 'TopRight' 106 | | 'Bottom' 107 | | 'BottomLeft' 108 | | 'BottomRight'; 109 | 110 | export type Style = { 111 | [key: string]: string[] | string | number | boolean | Style | Style[]; 112 | }; 113 | 114 | export enum ConfigType { 115 | fontSize = `fontSize`, 116 | lineHeight = `lineHeight`, 117 | } 118 | 119 | export type NullStyle = { 120 | kind: 'null'; 121 | }; 122 | 123 | export type CompleteStyle = { 124 | kind: 'complete'; 125 | style: Style; 126 | }; 127 | 128 | export type OrderedStyle = { 129 | kind: `ordered`; 130 | order: number; 131 | styleIr: StyleIR; 132 | }; 133 | 134 | export type DependentStyle = { 135 | kind: 'dependent'; 136 | complete: (style: Style) => string | void; 137 | }; 138 | 139 | /** 140 | * An "Intermediate Representation" of a style object, 141 | * that may, or may not require some post-processing, 142 | * merging with other styles, etc. 143 | */ 144 | export type StyleIR = NullStyle | OrderedStyle | DependentStyle | CompleteStyle; 145 | 146 | export enum Unit { 147 | rem = `rem`, 148 | em = `em`, 149 | px = `px`, 150 | percent = `%`, 151 | vw = `vw`, 152 | vh = `vh`, 153 | deg = `deg`, 154 | rad = `rad`, 155 | none = ``, 156 | } 157 | 158 | type NotImplemented = (...args: any) => unknown; 159 | 160 | export type AddedUtilities = Record; 161 | 162 | export type PluginFunction = (obj: { 163 | addUtilities(utilities: AddedUtilities): unknown; 164 | 165 | /** 166 | * @deprecated not supported in @jaredh159/twrnc 167 | */ 168 | addComponents: NotImplemented; 169 | 170 | /** 171 | * @deprecated not supported in @jaredh159/twrnc 172 | */ 173 | addBase: NotImplemented; 174 | 175 | /** 176 | * @deprecated not supported in @jaredh159/twrnc 177 | */ 178 | addVariant: NotImplemented; 179 | 180 | /** 181 | * @deprecated not supported in @jaredh159/twrnc 182 | */ 183 | e: NotImplemented; 184 | 185 | /** 186 | * @deprecated not supported in @jaredh159/twrnc 187 | */ 188 | prefix: NotImplemented; 189 | 190 | /** 191 | * @deprecated not supported in @jaredh159/twrnc 192 | */ 193 | theme: NotImplemented; 194 | 195 | /** 196 | * @deprecated not supported in @jaredh159/twrnc 197 | */ 198 | variants: NotImplemented; 199 | 200 | /** 201 | * @deprecated not supported in @jaredh159/twrnc 202 | */ 203 | config: NotImplemented; 204 | 205 | /** 206 | * @deprecated not supported in @jaredh159/twrnc 207 | */ 208 | corePlugins: NotImplemented; 209 | 210 | /** 211 | * @deprecated not supported in @jaredh159/twrnc 212 | */ 213 | matchUtilities: NotImplemented; 214 | 215 | /** 216 | * @deprecated not supported in @jaredh159/twrnc 217 | */ 218 | postcss: unknown; 219 | }) => unknown; 220 | 221 | export type CreatePlugin = (pluginFunction: PluginFunction) => { 222 | handler: PluginFunction; 223 | config: undefined; 224 | }; 225 | 226 | export function isString(value: unknown): value is string { 227 | return typeof value === `string`; 228 | } 229 | 230 | export function isObject(value: unknown): value is Record { 231 | return typeof value === `object`; 232 | } 233 | -------------------------------------------------------------------------------- /supported-utilities.md: -------------------------------------------------------------------------------- 1 | # Key 2 | 3 | - ✅ = Implemented, exists in tailwindcss AND RN 4 | - 😎 = Implemented, but is RN ONLY 5 | - 🚨 = No tailwindcss equivalent, not implemented 6 | 7 | ## RN Styles 8 | 9 | - ✅ alignContent 10 | - ✅ alignItems 11 | - ✅ alignSelf 12 | - ✅ aspectRatio 13 | - 🚨 backfaceVisibility 14 | - ✅ backgroundColor 15 | - ✅ borderBottomColor 16 | - 🚨 borderBottomEndRadius 17 | - ✅ borderBottomLeftRadius 18 | - ✅ borderBottomRightRadius 19 | - 🚨 borderBottomStartRadius 20 | - ✅ borderBottomWidth 21 | - ✅ borderColor 22 | - 🚨 borderEndColor 23 | - 🚨 borderEndWidth 24 | - ✅ borderLeftColor 25 | - ✅ borderLeftWidth 26 | - ✅ borderRadius 27 | - ✅ borderRightColor 28 | - ✅ borderRightWidth 29 | - 🚨 borderStartColor 30 | - 🚨 borderStartWidth 31 | - ✅ borderStyle 32 | - ✅ borderTopColor 33 | - 🚨 borderTopEndRadius 34 | - ✅ borderTopLeftRadius 35 | - ✅ borderTopRightRadius 36 | - 🚨 borderTopStartRadius 37 | - ✅ borderTopWidth 38 | - ✅ borderWidth 39 | - ✅ bottom 40 | - ✅ color 41 | - ✅ columnGap (requires RN >= 0.71) 42 | - 😎 direction (added: `direction-inherit`, `direction-ltr`, `direction-rtl`) 43 | - ✅ display 44 | - 😎 elevation (android-only) 45 | - 🚨 end 46 | - 🚨 flex (RN implementation does not match web/tailwindcss) 47 | - ✅ flexDirection 48 | - ✅ flexBasis 49 | - ✅ flexGrow 50 | - ✅ flexShrink 51 | - ✅ flexWrap 52 | - ✅ fontFamily 53 | - ✅ fontSize 54 | - ✅ fontStyle 55 | - ✅ fontVariant 56 | - ✅ fontWeight (😎 added: `font-100/200...900`) 57 | - ✅ gap (requires RN >= 0.71) 58 | - ✅ height 59 | - 😎 includeFontPadding (android, added: `include-font-padding`, `remove-font-padding`) 60 | - ✅ justifyContent 61 | - ✅ left 62 | - ✅ letterSpacing 63 | - ✅ lineHeight 64 | - ✅ margin 65 | - ✅ marginBottom 66 | - 🚨 marginEnd 67 | - ✅ marginHorizontal 68 | - ✅ marginLeft 69 | - ✅ marginRight 70 | - 🚨 marginStart 71 | - ✅ marginTop 72 | - ✅ marginVertical 73 | - ✅ maxHeight 74 | - ✅ maxWidth 75 | - ✅ minHeight 76 | - ✅ minWidth 77 | - ✅ opacity 78 | - ✅ overflow 79 | - 🚨 overlayColor (android only) 80 | - ✅ padding 81 | - ✅ paddingBottom 82 | - 🚨 paddingEnd 83 | - ✅ paddingHorizontal 84 | - ✅ paddingLeft 85 | - ✅ paddingRight 86 | - 🚨 paddingStart 87 | - ✅ paddingTop 88 | - ✅ paddingVertical 89 | - ✅ position 90 | - 🚨 resizeMode // maybe TODO? add classes? 91 | - ✅ right 92 | - ✅ rowGap (requires RN >= 0.71) 93 | - ✅ shadowColor `shadow-red-200` 94 | - ✅ shadowOffset (ios only) `shadow-offset-width|height-1|[3px]` 95 | - ✅ shadowOpacity (ios only) `shadow-opacity-70` 96 | - ✅ shadowRadius `shadow-radius-1[3px]` 97 | - 🚨 start 98 | - ✅ textAlign 99 | - ✅ textAlignVertical (android only) 100 | - 🚨 textDecorationColor (ios only) 101 | - ✅ textDecorationLine 102 | - 🚨 textDecorationStyle (ios only) 103 | - 🚨 textShadowColor 104 | - 🚨 textShadowOffset 105 | - 🚨 textShadowRadius 106 | - ✅ textTransform 107 | - 😎 tintColor 108 | - ✅ top 109 | - ✅ width 110 | - 🚨 writingDirection 111 | - ✅ zIndex 112 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "outDir": "./dist/cjs", 6 | "declaration": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "esModuleInterop": true, 5 | "noUncheckedIndexedAccess": true, 6 | "noImplicitOverride": true, 7 | "skipLibCheck": true, 8 | "rootDir": "./src", 9 | "outDir": "./dist/esm", 10 | "lib": ["ES2019"], 11 | "target": "ES2019", 12 | "declaration": true, 13 | "moduleResolution": "node", 14 | "jsx": "react", 15 | "stripInternal": true 16 | }, 17 | "exclude": ["./dist", "**/*.spec.ts"] 18 | } 19 | --------------------------------------------------------------------------------