├── .changeset └── config.json ├── .github └── workflows │ ├── check.yml │ └── release.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __tests__ ├── data │ ├── different-function-forms.input.js │ ├── different-function-forms.output.js │ ├── dynamic-hook-injection.input.js │ ├── dynamic-hook-injection.output.js │ ├── multiple-create-calls.input.js │ ├── multiple-create-calls.output.js │ ├── no-use.input.js │ ├── no-use.output.js │ ├── single-use.input.js │ ├── single-use.output.js │ ├── stylesheet-import-name.input.js │ └── stylesheet-import-name.output.js └── plugin.test.ts ├── package.json ├── plugin.js ├── react-native-dark-Hero.png ├── sample ├── .expo-shared │ └── assets.json ├── .gitignore ├── App.tsx ├── Card.tsx ├── app.json ├── assets │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash.png ├── babel.config.js ├── metro.config.js ├── package.json ├── scripts │ ├── App.transformed.js │ └── transform-app.js ├── shim.d.ts ├── tsconfig.json ├── webpack.config.js ├── yarn-error.log └── yarn.lock ├── shim.d.ts ├── src ├── index.tsx └── plugin.ts ├── tsconfig.build.json ├── tsconfig.json ├── vitest.config.js ├── yarn-error.log └── yarn.lock /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": [ 4 | "@svitejs/changesets-changelog-github-compact", 5 | { 6 | "repo": "FormidableLabs/react-native-dark" 7 | } 8 | ], 9 | "access": "public", 10 | "baseBranch": "main" 11 | } -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Typecheck and Unit Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | pull_request: 8 | 9 | jobs: 10 | ci-check: 11 | name: Typecheck and Unit Tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: 16 18 | - name: Install dependencies 19 | run: yarn install 20 | - name: Typecheck 21 | run: yarn typecheck 22 | - name: Unit Tests 23 | run: yarn test 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | id-token: write 13 | issues: write 14 | repository-projects: write 15 | deployments: write 16 | packages: write 17 | pull-requests: write 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 18 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | 27 | - name: Build Package 28 | run: yarn build 29 | 30 | - name: Unit Tests 31 | run: yarn test 32 | 33 | - name: PR or Publish 34 | id: changesets 35 | uses: changesets/action@v1 36 | with: 37 | version: yarn changeset version 38 | publish: yarn changeset publish 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | # Packing artifacts 5 | *.tgz 6 | package 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "semi": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # react-native-dark 2 | 3 | ## 0.1.4 4 | 5 | ### Patch Changes 6 | 7 | - fixes shim for react-native 0.73+ ([#8](https://github.com/FormidableLabs/react-native-dark/pull/8)) 8 | 9 | ## 0.1.3 10 | 11 | ### Patch Changes 12 | 13 | - Adding GitHub Action workflow ([#6](https://github.com/FormidableLabs/react-native-dark/pull/6)) 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for contributing to react-native-dark! 4 | 5 | ## Development 6 | 7 | To setup your environment: 8 | 9 | ```sh 10 | $ yarn install 11 | $ yarn typecheck 12 | $ yarn test 13 | 14 | ``` 15 | 16 | ### Using changesets 17 | 18 | Our official release path is to use automation to perform the actual publishing of our packages. The steps are to: 19 | 20 | 1. A human developer adds a changeset. Ideally this is as a part of a PR that will have a version impact on a package. 21 | 2. On merge of a PR our automation system opens a "Version Packages" PR. 22 | 3. On merging the "Version Packages" PR, the automation system publishes the packages. 23 | 24 | Here are more details: 25 | 26 | ### Add a changeset 27 | 28 | When you would like to add a changeset (which creates a file indicating the type of change), in your branch/PR issue this command: 29 | 30 | ```sh 31 | $ yarn changeset 32 | ``` 33 | 34 | to produce an interactive menu. Navigate the packages with arrow keys and hit `` to select 1+ packages. Hit `` when done. Select semver versions for packages and add appropriate messages. From there, you'll be prompted to enter a summary of the change. Some tips for this summary: 35 | 36 | 1. Aim for a single line, 1+ sentences as appropriate. 37 | 2. Include issue links in GH format (e.g. `#123`). 38 | 3. You don't need to reference the current pull request or whatnot, as that will be added later automatically. 39 | 40 | After this, you'll see a new uncommitted file in `.changesets` like: 41 | 42 | ```sh 43 | $ git status 44 | # .... 45 | Untracked files: 46 | (use "git add ..." to include in what will be committed) 47 | .changeset/flimsy-pandas-marry.md 48 | ``` 49 | 50 | Review the file, make any necessary adjustments, and commit it to source. When we eventually do a package release, the changeset notes and version will be incorporated! 51 | 52 | ### Creating versions 53 | 54 | On a merge of a feature PR, the changesets GitHub action will open a new PR titled `"Version Packages"`. This PR is automatically kept up to date with additional PRs with changesets. So, if you're not ready to publish yet, just keep merging feature PRs and then merge the version packages PR later. 55 | 56 | ### Publishing packages 57 | 58 | On the merge of a version packages PR, the changesets GitHub action will publish the packages to npm. 59 | 60 | ### Manually Releasing a new version to NPM 61 | 62 |
63 | 64 | Only for project administrators 65 | 66 | 67 | 1. Update `CHANGELOG.md`, following format for previous versions 68 | 2. Commit as "Changes for version VERSION" 69 | 3. Run `npm version patch` (or `minor|major|VERSION`) to run tests and lint, 70 | build published directories, then update `package.json` + add a git tag. 71 | 4. Run `npm publish` and publish to NPM if all is well. 72 | 5. Run `git push && git push --tags` 73 | 74 |
75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Formidable 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![React Native Dark — Formidable, We build the modern web](https://raw.githubusercontent.com/FormidableLabs/react-native-dark/main/react-native-dark-Hero.png)](https://formidable.com/open-source/) 2 | 3 | `react-native-dark` is a minor augmentation of `StyleSheet.create` to support dynamic dark-mode styling with little hassle, made possible by babel. 4 | 5 | A little, illustrative example: 6 | 7 | ```tsx 8 | import { StyleSheet, Text, View } from "react-native"; 9 | 10 | export default function App() { 11 | return ( 12 | 13 | Hello, world! 14 | 15 | ); 16 | } 17 | 18 | const styles = createStyleSheet({ 19 | container: { 20 | flex: 1, 21 | backgroundColor: "white", 22 | // 🎉 dark mode 🎉 23 | $dark: { 24 | backgroundColor: "black", 25 | }, 26 | }, 27 | 28 | title: { 29 | color: "black", 30 | // 🎉 dark mode 🎉 31 | $dark: { 32 | color: "white", 33 | }, 34 | }, 35 | }); 36 | ``` 37 | 38 | ## Setup 39 | 40 | Setup involves three steps. 41 | 42 | ### Step 1: Installation 43 | 44 | From a React Native (or Expo) project, install `react-native-dark` from npm: 45 | 46 | ```shell 47 | npm install react-native-dark # npm 48 | yarn add react-native-dark # yarn 49 | pnpm add react-native-dark # pnpm 50 | ``` 51 | 52 | ### Step 2: Add the babel plugin 53 | 54 | In your babel configuration (in e.g. `babel.config.js`), add the `react-native-dark` babel plugin: 55 | 56 | ```js 57 | module.exports = function() { 58 | return { 59 | // ... 60 | plugins: ["react-native-dark/plugin"], // 👈 add this 61 | }; 62 | }; 63 | ``` 64 | 65 | ### Step 3: Add the TypeScript shim for `StyleSheet.create` 66 | 67 | We'll also "shim" the type for `StyleSheet.create` to enhance the developer experience. Add a declaration file to your project, such as `shim.d.ts` and add the following line: 68 | 69 | ```ts 70 | /// 71 | ``` 72 | 73 | ### Step 4: Go to town! 74 | 75 | You're ready to start adding dark-mode styles to your app! See below for more details on usage. 76 | 77 | ## Usage 78 | 79 | The babel plugin and TS shim were built to make adding dark-mode support to your app as easy as just declaring dark-mode styles in your stylesheets. In a standard style declaration, just add a `$dark` field with the styles to be applied in dark mode! These styles will be applied _on top_ of the baseline styles. 80 | 81 | ```ts 82 | import { StyleSheet } from "react-native"; 83 | 84 | // ... 85 | 86 | const styles = StyleSheet.create({ 87 | card: { 88 | padding: 8, 89 | borderRadius: 8, 90 | backgroundColor: "lightblue", 91 | 92 | // 🪄 magic happens here 🪄 93 | $dark: { 94 | backgroundColor: "blue" 95 | } 96 | } 97 | }); 98 | ``` 99 | 100 | Now when you call `styles.card` within your function components, the value will be automagically dynamic based on color scheme preference. 101 | 102 | ### Manually setting color mode 103 | 104 | By default, `$dark` styles will be applied when the user's device color scheme preference is set to `dark`. However, you can manually override this behavior by wrapping a component tree in `DarkModeProvider` from `react-native-dark`. 105 | 106 | ```tsx 107 | import { DarkModeProvider } from "react-native-dark"; 108 | 109 | // Example of forcing dark mode and ignore user's color scheme preference 110 | const App = () => { 111 | return ( 112 | 113 | 114 | 115 | ) 116 | } 117 | ``` 118 | 119 | The `DarkModeProvider` has a single `colorMode` prop that can accept: 120 | 121 | - `"auto"` (default) to respect user's color scheme preference; 122 | - `"light"` to force light mode; 123 | - `"dark"` to force dark mode. 124 | 125 | ## 🦄 Magical, but not magic 126 | 127 | The babel plugin does the heavy lifting here and will turn code like the following: 128 | 129 | ```tsx 130 | import { StyleSheet, View } from "react-native"; 131 | 132 | export const App = () => { 133 | return ; 134 | } 135 | 136 | const styles = StyleSheet.create({ 137 | container: { 138 | flex: 1, 139 | backgroundColor: "white", 140 | 141 | $dark: { 142 | backgroundColor: "black" 143 | } 144 | } 145 | }); 146 | ``` 147 | 148 | into something like this: 149 | 150 | ```tsx 151 | import { StyleSheet, View } from "react-native"; 152 | import { useDarkMode } from "react-native-dark"; 153 | 154 | export const App = () => { 155 | const isDark = useDarkMode(); 156 | 157 | return ; 158 | } 159 | 160 | const styles = StyleSheet.create({ 161 | container: { 162 | flex: 1, 163 | backgroundColor: "white", 164 | }, 165 | __container__$dark: { 166 | backgroundColor: "black" 167 | } 168 | }); 169 | 170 | const __styles__container__$dark = StyleSheet.compose(styles.container, styles.__container__$dark); 171 | ``` 172 | 173 | This is a reasonable and performant approach that you might take _by hand_ if you were implementing dark mode by hand. `react-native-dark` just cuts out the extra code for you. This, however, comes with a limitation or two... 174 | 175 | ### Limitations 176 | 177 | 1. Styles should be defined in the same file that they are referenced. E.g., don't import/export your styles object – define them in the same file that they're used. 178 | 1. The dynamic support is handled by the `useColorScheme` hook from React Native, therefore this library only currently supports function components. 179 | 1. Who knows, we'll probably find more limitations as we go 🤷‍ 180 | 181 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /__tests__/data/different-function-forms.input.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, StyleSheet } from "react-native"; 3 | 4 | function App() { 5 | return React.createElement(View, {}, [ 6 | React.createElement(FuncDeclaration), 7 | React.createElement(FE), 8 | React.createElement(ArrowFunc), 9 | React.createElement(ArrowFuncImplicitReturn), 10 | ]); 11 | } 12 | 13 | function FuncDeclaration() { 14 | return React.createElement(View, { style: styles.container }); 15 | } 16 | 17 | const FE = function FuncExpression() { 18 | return React.createElement(View, { style: styles.container }); 19 | }; 20 | 21 | const ArrowFunc = () => { 22 | return React.createElement(View, { style: styles.container }); 23 | }; 24 | 25 | const ArrowFuncImplicitReturn = () => 26 | React.createElement(View, { style: styles.container }); 27 | 28 | const styles = StyleSheet.create({ 29 | container: { 30 | flex: 1, 31 | backgroundColor: "white", 32 | $dark: { 33 | backgroundColor: "black", 34 | }, 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /__tests__/data/different-function-forms.output.js: -------------------------------------------------------------------------------- 1 | import { useDarkMode as _useDarkMode } from "react-native-dark"; 2 | import * as React from "react"; 3 | import { View, StyleSheet } from "react-native"; 4 | 5 | function App() { 6 | return React.createElement(View, {}, [ 7 | React.createElement(FuncDeclaration), 8 | React.createElement(FE), 9 | React.createElement(ArrowFunc), 10 | React.createElement(ArrowFuncImplicitReturn), 11 | ]); 12 | } 13 | 14 | function FuncDeclaration() { 15 | const _REACT_NATIVE_DARK_isDark = _useDarkMode(); 16 | 17 | return React.createElement(View, { 18 | style: _REACT_NATIVE_DARK_isDark 19 | ? __styles__container__$dark 20 | : styles.container, 21 | }); 22 | } 23 | 24 | const FE = function FuncExpression() { 25 | const _REACT_NATIVE_DARK_isDark2 = _useDarkMode(); 26 | 27 | return React.createElement(View, { 28 | style: _REACT_NATIVE_DARK_isDark2 29 | ? __styles__container__$dark 30 | : styles.container, 31 | }); 32 | }; 33 | 34 | const ArrowFunc = () => { 35 | const _REACT_NATIVE_DARK_isDark3 = _useDarkMode(); 36 | 37 | return React.createElement(View, { 38 | style: _REACT_NATIVE_DARK_isDark3 39 | ? __styles__container__$dark 40 | : styles.container, 41 | }); 42 | }; 43 | 44 | const ArrowFuncImplicitReturn = () => { 45 | const _REACT_NATIVE_DARK_isDark4 = _useDarkMode(); 46 | 47 | return React.createElement(View, { 48 | style: _REACT_NATIVE_DARK_isDark4 49 | ? __styles__container__$dark 50 | : styles.container, 51 | }); 52 | }; 53 | 54 | const styles = StyleSheet.create({ 55 | container: { 56 | flex: 1, 57 | backgroundColor: "white", 58 | }, 59 | __container__$dark: { 60 | backgroundColor: "black", 61 | }, 62 | }); 63 | 64 | const __styles__container__$dark = StyleSheet.compose( 65 | styles.container, 66 | styles.__container__$dark, 67 | ); 68 | -------------------------------------------------------------------------------- /__tests__/data/dynamic-hook-injection.input.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, StyleSheet } from "react-native"; 3 | 4 | function App() { 5 | console.log(styles.title); 6 | return React.createElement(Body); 7 | } 8 | 9 | function Body() { 10 | return React.createElement(View, { style: styles.container }); 11 | } 12 | 13 | const styles = StyleSheet.create({ 14 | container: { 15 | flex: 1, 16 | backgroundColor: "white", 17 | $dark: { 18 | backgroundColor: "black", 19 | }, 20 | }, 21 | title: {}, 22 | }); 23 | -------------------------------------------------------------------------------- /__tests__/data/dynamic-hook-injection.output.js: -------------------------------------------------------------------------------- 1 | import { useDarkMode as _useDarkMode } from "react-native-dark"; 2 | import * as React from "react"; 3 | import { View, StyleSheet } from "react-native"; 4 | 5 | function App() { 6 | console.log(styles.title); 7 | return React.createElement(Body); 8 | } 9 | 10 | function Body() { 11 | const _REACT_NATIVE_DARK_isDark = _useDarkMode(); 12 | 13 | return React.createElement(View, { 14 | style: _REACT_NATIVE_DARK_isDark 15 | ? __styles__container__$dark 16 | : styles.container, 17 | }); 18 | } 19 | 20 | const styles = StyleSheet.create({ 21 | container: { 22 | flex: 1, 23 | backgroundColor: "white", 24 | }, 25 | title: {}, 26 | __container__$dark: { 27 | backgroundColor: "black", 28 | }, 29 | }); 30 | 31 | const __styles__container__$dark = StyleSheet.compose( 32 | styles.container, 33 | styles.__container__$dark, 34 | ); 35 | -------------------------------------------------------------------------------- /__tests__/data/multiple-create-calls.input.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, Text, StyleSheet } from "react-native"; 3 | 4 | function App() { 5 | return React.createElement( 6 | View, 7 | { style: styles.container }, 8 | React.createElement(Text, { style: otherStyles.title }), 9 | ); 10 | } 11 | 12 | const styles = StyleSheet.create({ 13 | container: { 14 | flex: 1, 15 | backgroundColor: "white", 16 | $dark: { 17 | backgroundColor: "black", 18 | }, 19 | }, 20 | }); 21 | 22 | const otherStyles = StyleSheet.create({ 23 | title: { 24 | color: "black", 25 | $dark: { 26 | color: "white", 27 | }, 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /__tests__/data/multiple-create-calls.output.js: -------------------------------------------------------------------------------- 1 | import { useDarkMode as _useDarkMode } from "react-native-dark"; 2 | import * as React from "react"; 3 | import { View, Text, StyleSheet } from "react-native"; 4 | 5 | function App() { 6 | const _REACT_NATIVE_DARK_isDark = _useDarkMode(); 7 | 8 | return React.createElement( 9 | View, 10 | { 11 | style: _REACT_NATIVE_DARK_isDark 12 | ? __styles__container__$dark 13 | : styles.container, 14 | }, 15 | React.createElement(Text, { 16 | style: _REACT_NATIVE_DARK_isDark 17 | ? __otherStyles__title__$dark 18 | : otherStyles.title, 19 | }), 20 | ); 21 | } 22 | 23 | const styles = StyleSheet.create({ 24 | container: { 25 | flex: 1, 26 | backgroundColor: "white", 27 | }, 28 | __container__$dark: { 29 | backgroundColor: "black", 30 | }, 31 | }); 32 | const otherStyles = StyleSheet.create({ 33 | title: { 34 | color: "black", 35 | }, 36 | __title__$dark: { 37 | color: "white", 38 | }, 39 | }); 40 | 41 | const __styles__container__$dark = StyleSheet.compose( 42 | styles.container, 43 | styles.__container__$dark, 44 | ); 45 | 46 | const __otherStyles__title__$dark = StyleSheet.compose( 47 | otherStyles.title, 48 | otherStyles.__title__$dark, 49 | ); 50 | -------------------------------------------------------------------------------- /__tests__/data/no-use.input.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, StyleSheet } from "react-native"; 3 | 4 | function App() { 5 | return React.createElement(View, { 6 | style: styles.container, 7 | }); 8 | } 9 | 10 | const styles = StyleSheet.create({ 11 | container: { 12 | flex: 1, 13 | backgroundColor: "white", 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /__tests__/data/no-use.output.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, StyleSheet } from "react-native"; 3 | 4 | function App() { 5 | return React.createElement(View, { 6 | style: styles.container, 7 | }); 8 | } 9 | 10 | const styles = StyleSheet.create({ 11 | container: { 12 | flex: 1, 13 | backgroundColor: "white", 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /__tests__/data/single-use.input.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, StyleSheet } from "react-native"; 3 | 4 | function App() { 5 | return React.createElement(View, { style: styles.container }); 6 | } 7 | 8 | const styles = StyleSheet.create({ 9 | container: { 10 | flex: 1, 11 | backgroundColor: "white", 12 | $dark: { 13 | backgroundColor: "black" 14 | } 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /__tests__/data/single-use.output.js: -------------------------------------------------------------------------------- 1 | import { useDarkMode as _useDarkMode } from "react-native-dark"; 2 | import * as React from "react"; 3 | import { View, StyleSheet } from "react-native"; 4 | 5 | function App() { 6 | const _REACT_NATIVE_DARK_isDark = _useDarkMode(); 7 | 8 | return React.createElement(View, { 9 | style: _REACT_NATIVE_DARK_isDark 10 | ? __styles__container__$dark 11 | : styles.container, 12 | }); 13 | } 14 | 15 | const styles = StyleSheet.create({ 16 | container: { 17 | flex: 1, 18 | backgroundColor: "white", 19 | }, 20 | __container__$dark: { 21 | backgroundColor: "black", 22 | }, 23 | }); 24 | 25 | const __styles__container__$dark = StyleSheet.compose( 26 | styles.container, 27 | styles.__container__$dark, 28 | ); 29 | -------------------------------------------------------------------------------- /__tests__/data/stylesheet-import-name.input.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { View, StyleSheet as MyStyleSheet } from "react-native"; 3 | 4 | function App() { 5 | return React.createElement(View, { style: styles.container }); 6 | } 7 | 8 | const styles = MyStyleSheet.create({ 9 | container: { 10 | flex: 1, 11 | backgroundColor: "white", 12 | $dark: { 13 | backgroundColor: "black", 14 | }, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /__tests__/data/stylesheet-import-name.output.js: -------------------------------------------------------------------------------- 1 | import { useDarkMode as _useDarkMode } from "react-native-dark"; 2 | import * as React from "react"; 3 | import { View, StyleSheet as MyStyleSheet } from "react-native"; 4 | 5 | function App() { 6 | const _REACT_NATIVE_DARK_isDark = _useDarkMode(); 7 | 8 | return React.createElement(View, { 9 | style: _REACT_NATIVE_DARK_isDark 10 | ? __styles__container__$dark 11 | : styles.container, 12 | }); 13 | } 14 | 15 | const styles = MyStyleSheet.create({ 16 | container: { 17 | flex: 1, 18 | backgroundColor: "white", 19 | }, 20 | __container__$dark: { 21 | backgroundColor: "black", 22 | }, 23 | }); 24 | 25 | const __styles__container__$dark = MyStyleSheet.compose( 26 | styles.container, 27 | styles.__container__$dark, 28 | ); 29 | -------------------------------------------------------------------------------- /__tests__/plugin.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import plugin from "../src/plugin"; 3 | import babel from "@babel/core"; 4 | import * as fs from "node:fs"; 5 | import * as path from "node:path"; 6 | import { format } from "prettier"; 7 | 8 | // Get test names from ./data directory 9 | const testFileNames = Array.from( 10 | new Set( 11 | fs 12 | .readdirSync(path.join(__dirname, "data")) 13 | .map((fn) => fn.replace(/\.(input|output)\.js$/, "")), 14 | ), 15 | ); 16 | 17 | // Grab test input/output data 18 | const testData = testFileNames.reduce>( 19 | (acc, name) => { 20 | const input = fs.readFileSync( 21 | path.join(__dirname, "data", `${name}.input.js`), 22 | "utf-8", 23 | ); 24 | const output = fs.readFileSync( 25 | path.join(__dirname, "data", `${name}.output.js`), 26 | "utf-8", 27 | ); 28 | acc[name] = [input, output]; 29 | return acc; 30 | }, 31 | {}, 32 | ); 33 | 34 | /** 35 | * Transform code with plugin 36 | */ 37 | const transform = (code: string) => { 38 | const transformed = babel.transform(code, { 39 | filename: "foobar.ts", 40 | plugins: [plugin], 41 | }); 42 | 43 | const output = transformed?.code || ""; 44 | return output; 45 | }; 46 | 47 | describe("babel-plugin", () => { 48 | it.each(Object.keys(testData))("Transforms %s appropriately", (key) => { 49 | expect(prettify(transform(testData[key][0]))).to.equal( 50 | prettify(testData[key][1]), 51 | ); 52 | }); 53 | }); 54 | 55 | const prettify = (str: string) => format(str, { parser: "babel" }); 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-dark", 3 | "author": "Formidable", 4 | "private": false, 5 | "description": "Tiny wrapper around React Native's StyleSheet.create API to easily support dark mode.", 6 | "keywords": [ 7 | "react", 8 | "react-native", 9 | "dark-mode" 10 | ], 11 | "version": "0.1.4", 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "files": [ 15 | "dist/", 16 | "plugin.js", 17 | "shim.d.ts" 18 | ], 19 | "repository": { 20 | "url": "https://github.com/FormidableLabs/react-native-dark/" 21 | }, 22 | "homepage": "https://github.com/FormidableLabs/react-native-dark/", 23 | "scripts": { 24 | "build": "rm -rf dist && tsc -p ./tsconfig.build.json", 25 | "dev": "tsc --watch", 26 | "typecheck": "tsc --noEmit", 27 | "test": "vitest run", 28 | "test:watch": "vitest", 29 | "prepublishOnly": "yarn test && yarn build", 30 | "prepack": "yarn build", 31 | "preversion": "yarn test" 32 | }, 33 | "publishConfig": { 34 | "provenance": true 35 | }, 36 | "license": "MIT", 37 | "peerDependencies": { 38 | "react": ">=16.0.0", 39 | "react-native": ">=0.66.4" 40 | }, 41 | "dependencies": { 42 | "@babel/core": "^7.19.0", 43 | "@babel/helper-module-imports": "^7.18.6" 44 | }, 45 | "devDependencies": { 46 | "@babel/code-frame": "^7.18.6", 47 | "@babel/preset-env": "^7.19.0", 48 | "@babel/preset-react": "^7.18.6", 49 | "@babel/types": "^7.19.0", 50 | "@changesets/cli": "^2.26.1", 51 | "@svitejs/changesets-changelog-github-compact": "^0.1.1", 52 | "@testing-library/react-hooks": "^8.0.1", 53 | "@testing-library/react-native": "^10.1.1", 54 | "@types/babel__core": "^7.1.19", 55 | "@types/prettier": "^2.7.0", 56 | "@types/react": "^18.0.15", 57 | "@types/react-native": "^0.69.2", 58 | "babel-preset-expo": "^9.2.0", 59 | "concurrently": "^7.2.2", 60 | "nodemon": "^2.0.19", 61 | "prettier": "^2.7.1", 62 | "react": "^18.2.0", 63 | "react-native": "0.68.2", 64 | "react-test-renderer": "^18.2.0", 65 | "typescript": "~4.3.5", 66 | "vitest": "^0.18.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /plugin.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./dist/plugin").default; 2 | -------------------------------------------------------------------------------- /react-native-dark-Hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-dark/9644519aa2dad094b3f6589750ebebb489a9397c/react-native-dark-Hero.png -------------------------------------------------------------------------------- /sample/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /sample/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /sample/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { StyleSheet, Text, View } from "react-native"; 3 | import { DarkModeProvider } from "react-native-dark"; 4 | 5 | const App = () => { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | }; 12 | 13 | const Body = () => { 14 | return ( 15 | 16 | Hello world! 17 | 18 | ); 19 | }; 20 | 21 | export default App; 22 | 23 | const styles = StyleSheet.create({ 24 | container: { 25 | flex: 1, 26 | justifyContent: "center", 27 | alignItems: "center", 28 | backgroundColor: "white", 29 | 30 | $dark: { 31 | backgroundColor: "black", 32 | }, 33 | }, 34 | 35 | title: { 36 | color: "black", 37 | fontSize: 24, 38 | 39 | $dark: { 40 | color: "white", 41 | fontWeight: "bold", 42 | }, 43 | }, 44 | }); 45 | -------------------------------------------------------------------------------- /sample/Card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createStyleSheet } from "../dist"; 3 | import { Text, View } from "react-native"; 4 | 5 | export const Card = () => { 6 | return ( 7 | 8 | A card! 9 | 10 | ); 11 | }; 12 | 13 | const styles = createStyleSheet({ 14 | title: { 15 | backgroundColor: "red", 16 | borderRadius: 8, 17 | padding: 8, 18 | $dark: { 19 | backgroundColor: "pink", 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /sample/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "sample", 4 | "slug": "sample", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "automatic", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "updates": { 15 | "fallbackToCacheTimeout": 0 16 | }, 17 | "assetBundlePatterns": [ 18 | "**/*" 19 | ], 20 | "ios": { 21 | "supportsTablet": true 22 | }, 23 | "android": { 24 | "adaptiveIcon": { 25 | "foregroundImage": "./assets/adaptive-icon.png", 26 | "backgroundColor": "#FFFFFF" 27 | } 28 | }, 29 | "web": { 30 | "favicon": "./assets/favicon.png" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /sample/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-dark/9644519aa2dad094b3f6589750ebebb489a9397c/sample/assets/adaptive-icon.png -------------------------------------------------------------------------------- /sample/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-dark/9644519aa2dad094b3f6589750ebebb489a9397c/sample/assets/favicon.png -------------------------------------------------------------------------------- /sample/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-dark/9644519aa2dad094b3f6589750ebebb489a9397c/sample/assets/icon.png -------------------------------------------------------------------------------- /sample/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/react-native-dark/9644519aa2dad094b3f6589750ebebb489a9397c/sample/assets/splash.png -------------------------------------------------------------------------------- /sample/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | plugins: ["react-native-dark/plugin"], 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /sample/metro.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const escape = require("escape-string-regexp"); 3 | const { getDefaultConfig } = require("@expo/metro-config"); 4 | const exclusionList = require("metro-config/src/defaults/exclusionList"); 5 | const pak = require("../package.json"); 6 | 7 | const root = path.resolve(__dirname, ".."); 8 | 9 | const modules = Object.keys({ 10 | ...pak.peerDependencies, 11 | }); 12 | 13 | const defaultConfig = getDefaultConfig(__dirname); 14 | 15 | module.exports = { 16 | ...defaultConfig, 17 | 18 | projectRoot: __dirname, 19 | watchFolders: [root], 20 | 21 | // We need to make sure that only one version is loaded for peerDependencies 22 | // So we block them at the root, and alias them to the versions in example's node_modules 23 | resolver: { 24 | ...defaultConfig.resolver, 25 | 26 | blacklistRE: exclusionList( 27 | modules.map( 28 | (m) => 29 | new RegExp(`^${escape(path.join(root, "node_modules", m))}\\/.*$`), 30 | ), 31 | ), 32 | 33 | extraNodeModules: modules.reduce((acc, name) => { 34 | acc[name] = path.join(__dirname, "node_modules", name); 35 | return acc; 36 | }, {}), 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start --clear", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web" 10 | }, 11 | "dependencies": { 12 | "expo": "~46.0.9", 13 | "expo-status-bar": "~1.4.0", 14 | "nativewind": "^2.0.9", 15 | "prettier": "^2.7.1", 16 | "react": "18.0.0", 17 | "react-dom": "18.0.0", 18 | "react-native": "0.69.5", 19 | "react-native-dark": "./rn-dark.tgz", 20 | "react-native-web": "~0.18.7" 21 | }, 22 | "devDependencies": { 23 | "@babel/cli": "^7.18.10", 24 | "@babel/core": "^7.12.9", 25 | "@expo/metro-config": "^0.3.22", 26 | "@expo/webpack-config": "^0.17.2", 27 | "@types/react": "~18.0.14", 28 | "@types/react-native": "~0.69.1", 29 | "typescript": "~4.3.5" 30 | }, 31 | "private": true 32 | } 33 | -------------------------------------------------------------------------------- /sample/scripts/App.transformed.js: -------------------------------------------------------------------------------- 1 | Object.defineProperty(exports, "__esModule", { value: true }); 2 | exports.default = App; 3 | var _reactNativeDark = require("react-native-dark"); 4 | var React = _interopRequireWildcard(require("react")); 5 | var _reactNative = require("react-native"); 6 | var _jsxRuntime = require("react/jsx-runtime"); 7 | function _getRequireWildcardCache(nodeInterop) { 8 | if (typeof WeakMap !== "function") return null; 9 | var cacheBabelInterop = new WeakMap(); 10 | var cacheNodeInterop = new WeakMap(); 11 | return (_getRequireWildcardCache = function _getRequireWildcardCache( 12 | nodeInterop 13 | ) { 14 | return nodeInterop ? cacheNodeInterop : cacheBabelInterop; 15 | })(nodeInterop); 16 | } 17 | function _interopRequireWildcard(obj, nodeInterop) { 18 | if (!nodeInterop && obj && obj.__esModule) { 19 | return obj; 20 | } 21 | if (obj === null || (typeof obj !== "object" && typeof obj !== "function")) { 22 | return { default: obj }; 23 | } 24 | var cache = _getRequireWildcardCache(nodeInterop); 25 | if (cache && cache.has(obj)) { 26 | return cache.get(obj); 27 | } 28 | var newObj = {}; 29 | var hasPropertyDescriptor = 30 | Object.defineProperty && Object.getOwnPropertyDescriptor; 31 | for (var key in obj) { 32 | if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { 33 | var desc = hasPropertyDescriptor 34 | ? Object.getOwnPropertyDescriptor(obj, key) 35 | : null; 36 | if (desc && (desc.get || desc.set)) { 37 | Object.defineProperty(newObj, key, desc); 38 | } else { 39 | newObj[key] = obj[key]; 40 | } 41 | } 42 | } 43 | newObj.default = obj; 44 | if (cache) { 45 | cache.set(obj, newObj); 46 | } 47 | return newObj; 48 | } 49 | function App() { 50 | return (0, _jsxRuntime.jsxs)(_reactNative.View, { 51 | children: [ 52 | (0, _jsxRuntime.jsx)(Header, {}), 53 | (0, _jsxRuntime.jsx)(Body, {}), 54 | ], 55 | }); 56 | } 57 | var Header = function Header() { 58 | var _REACT_NATIVE_DARK_isDark = (0, _reactNativeDark.useDarkMode)(); 59 | return (0, _jsxRuntime.jsx)(_reactNative.View, { 60 | style: _REACT_NATIVE_DARK_isDark ? __styles__banner__$dark : styles.banner, 61 | }); 62 | }; 63 | var Body = function Body() { 64 | var _REACT_NATIVE_DARK_isDark2 = (0, _reactNativeDark.useDarkMode)(); 65 | return (0, _jsxRuntime.jsx)(_reactNative.Text, { 66 | style: _REACT_NATIVE_DARK_isDark2 ? __styles__title__$dark : styles.title, 67 | }); 68 | }; 69 | var Footer = function Footer() { 70 | var _REACT_NATIVE_DARK_isDark3 = (0, _reactNativeDark.useDarkMode)(); 71 | return (0, _jsxRuntime.jsx)(_reactNative.View, { 72 | style: _REACT_NATIVE_DARK_isDark3 73 | ? __otherStyles__foo__$dark 74 | : otherStyles.foo, 75 | }); 76 | }; 77 | var styles = _reactNative.StyleSheet.create({ 78 | container: { backgroundColor: "red" }, 79 | title: { fontSize: 24 }, 80 | banner: { padding: 8 }, 81 | __container__$dark: { fontWeight: "bold" }, 82 | __title__$dark: { fontSize: 18 }, 83 | __banner__$dark: { padding: 12 }, 84 | }); 85 | var otherStyles = _reactNative.StyleSheet.create({ 86 | foo: { backgroundColor: "red" }, 87 | __foo__$dark: { backgroundColor: "blue" }, 88 | }); 89 | var __styles__container__$dark = _reactNative.StyleSheet.compose( 90 | styles.container, 91 | styles.__container__$dark 92 | ); 93 | var __styles__title__$dark = _reactNative.StyleSheet.compose( 94 | styles.title, 95 | styles.__title__$dark 96 | ); 97 | var __styles__banner__$dark = _reactNative.StyleSheet.compose( 98 | styles.banner, 99 | styles.__banner__$dark 100 | ); 101 | var __otherStyles__foo__$dark = _reactNative.StyleSheet.compose( 102 | otherStyles.foo, 103 | otherStyles.__foo__$dark 104 | ); 105 | -------------------------------------------------------------------------------- /sample/scripts/transform-app.js: -------------------------------------------------------------------------------- 1 | const fs = require("node:fs/promises"); 2 | const path = require("node:path"); 3 | const babel = require("@babel/core"); 4 | const prettier = require("prettier"); 5 | 6 | const main = async () => { 7 | const AppContent = await fs.readFile( 8 | path.join(__dirname, "..", "App.tsx"), 9 | "utf-8", 10 | ); 11 | 12 | const transformed = babel.transform(AppContent, { 13 | // presets: ["babel-preset-expo"], 14 | // plugins: ["react-native-dark/babel"], 15 | filename: "App.tsx", 16 | }).code; 17 | 18 | const formatted = prettier.format(transformed); 19 | 20 | await fs.writeFile(path.join(__dirname, "App.transformed.js"), formatted); 21 | }; 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /sample/shim.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /sample/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /sample/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const createExpoWebpackConfigAsync = require('@expo/webpack-config'); 3 | const { resolver } = require('./metro.config'); 4 | 5 | const root = path.resolve(__dirname, '..'); 6 | const node_modules = path.join(__dirname, 'node_modules'); 7 | 8 | module.exports = async function (env, argv) { 9 | const config = await createExpoWebpackConfigAsync(env, argv); 10 | 11 | config.module.rules.push({ 12 | test: /\.(js|jsx|ts|tsx)$/, 13 | include: path.resolve(root, 'src'), 14 | use: 'babel-loader', 15 | }); 16 | 17 | // We need to make sure that only one version is loaded for peerDependencies 18 | // So we alias them to the versions in example's node_modules 19 | Object.assign(config.resolve.alias, { 20 | ...resolver.extraNodeModules, 21 | 'react-native-web': path.join(node_modules, 'react-native-web'), 22 | }); 23 | 24 | return config; 25 | }; 26 | -------------------------------------------------------------------------------- /shim.d.ts: -------------------------------------------------------------------------------- 1 | import "react-native"; 2 | import { type ImageStyle, type TextStyle, type ViewStyle } from "react-native"; 3 | 4 | module "react-native" { 5 | export * from "react-native"; 6 | 7 | export namespace StyleSheet { 8 | type AddDark = T & { $dark?: T }; 9 | type NamedStylesWithDark = { 10 | [P in keyof T]: 11 | | AddDark 12 | | AddDark 13 | | AddDark; 14 | }; 15 | 16 | /** 17 | * Create a stylesheet with dark-mode support via $dark property on style objects. 18 | */ 19 | export function create< 20 | T extends NamedStylesWithDark | NamedStylesWithDark, 21 | >(styles: T & NamedStylesWithDark): T; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useColorScheme } from "react-native"; 3 | 4 | export type ColorSchemeMode = "light" | "dark" | "auto"; 5 | const DarkModeContext = React.createContext("auto"); 6 | 7 | export const useDarkMode = () => { 8 | const colorMode = React.useContext(DarkModeContext) || "auto"; 9 | const deviceColorScheme = useColorScheme(); 10 | 11 | return ( 12 | colorMode === "dark" || 13 | (colorMode === "auto" && deviceColorScheme === "dark") 14 | ); 15 | }; 16 | 17 | export const DarkModeProvider = ({ 18 | colorMode, 19 | children, 20 | }: React.PropsWithChildren<{ colorMode: ColorSchemeMode }>) => { 21 | return ( 22 | 23 | {children} 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import * as babel from "@babel/core"; 2 | import type { PluginObj, NodePath } from "@babel/core"; 3 | // @ts-ignore 4 | import { addNamed } from "@babel/helper-module-imports"; 5 | import { 6 | ArrowFunctionExpression, 7 | BlockStatement, 8 | FunctionDeclaration, 9 | FunctionExpression, 10 | Identifier, 11 | MemberExpression, 12 | ObjectProperty, 13 | } from "@babel/types"; 14 | 15 | /** 16 | * We want to transform: 17 | * const styles = StyleSheet.create({foo: {$dark: {}}}) 18 | * into 19 | * const styles = StyleSheet.create({ foo: {}, __foo__$dark: {} }) 20 | * by extracting out the dark styles. 21 | * 22 | * Then we want to compose base/dark styles by creating a variable like: 23 | * const __styles__foo__$dark = StyleSheet.compose(styles.foo, styles.__foo__$dark); 24 | * 25 | * Then we want to find functions that reference this styles.foo (etc) property and: 26 | * - inject a const __isDark = useDarkMode(); into the top 27 | * - replace styles.foo with (!__isDark ? styles.foo : __styles__foo__$dark) 28 | */ 29 | 30 | export default function ({ types: t }: typeof babel): PluginObj { 31 | return { 32 | visitor: { 33 | Program: { 34 | enter(programPath) { 35 | /** 36 | * Step 1: Check if `StyleSheet` is imported from React Native and `StyleSheet.create` is called with a $dark 37 | */ 38 | let StyleSheetName: string | null = null; 39 | programPath.traverse({ 40 | ImportDeclaration(importPath) { 41 | if (importPath.node.source.value === "react-native") { 42 | for (let spec of importPath.node.specifiers) { 43 | if ( 44 | t.isImportSpecifier(spec) && 45 | t.isIdentifier(spec.imported) && 46 | spec.imported.name === "StyleSheet" && 47 | t.isIdentifier(spec.local) 48 | ) { 49 | StyleSheetName = spec.local.name; 50 | } 51 | } 52 | } 53 | }, 54 | }); 55 | if (!StyleSheetName) return; 56 | 57 | /** 58 | * Step 2: Check for a const [styles] = StyleSheet.create({ [property]: { $dark: {} } }); call, and rearrange accordingly 59 | */ 60 | // { styles: ['container', 'title'], ... } type of map to track which style lookups require dynamic adjusting 61 | const dynamicStyleMap = {} as Record; 62 | programPath.traverse({ 63 | VariableDeclarator(varDecPath) { 64 | const callPath = varDecPath.get("init"); 65 | if (!t.isCallExpression(callPath.node)) return; 66 | if (!t.isIdentifier(varDecPath.node.id)) return; 67 | 68 | let stylesName = varDecPath.node.id.name; 69 | dynamicStyleMap[stylesName] = []; 70 | 71 | if ( 72 | t.isMemberExpression(callPath.node.callee) && 73 | t.isIdentifier(callPath.node.callee.object) && 74 | callPath.node.callee.object.name === StyleSheetName && 75 | t.isIdentifier(callPath.node.callee.property) && 76 | callPath.node.callee.property.name === "create" 77 | ) { 78 | // At this point, callPath.node.callee is a MemberExpression. Grab argument, which is arg to StyleSheet.create 79 | const arg = callPath.node.arguments[0]; 80 | if (!arg || !t.isObjectExpression(arg)) return; 81 | 82 | // Loop through properties of the StyleSheet.create argument object 83 | // Check for ones that have a nested $dark property 84 | const nodesToInsert = [] as { 85 | node: ObjectProperty; 86 | ogName: string; 87 | }[]; 88 | for (const i in arg.properties) { 89 | const property = arg.properties[i]; 90 | if (!t.isProperty(property)) continue; 91 | if (!t.isIdentifier(property.key)) continue; 92 | if (!t.isObjectExpression(property.value)) continue; 93 | 94 | // Loop through fields of the style object (like fields of styles.container), look for $dark 95 | 96 | for (const j in property.value.properties) { 97 | const decProp = property.value.properties[j]; 98 | if (!t.isProperty(decProp)) continue; 99 | if ( 100 | t.isIdentifier(decProp.key) && 101 | decProp.key.name === "$dark" 102 | ) { 103 | // Keep track of e.g. `styles.container` as a call that will need dynamic dark mode support 104 | dynamicStyleMap[stylesName].push(property.key.name); 105 | 106 | // Extract path to $dark sub property 107 | const $darkPath = callPath.get( 108 | `arguments.0.properties.${i}.value.properties.${j}`, 109 | ) as NodePath; 110 | 111 | const clonedNode = t.cloneDeepWithoutLoc( 112 | // @ts-ignore 113 | $darkPath.node, 114 | ) as ObjectProperty; 115 | if (t.isIdentifier(clonedNode.key)) 116 | clonedNode.key.name = `__${property.key.name}__$dark`; 117 | 118 | nodesToInsert.push({ 119 | node: clonedNode, 120 | ogName: property.key.name, 121 | }); 122 | $darkPath.remove(); 123 | } 124 | } 125 | } 126 | 127 | nodesToInsert.forEach(({ node, ogName }) => { 128 | // Insert __property__$dark style declaration into StyleSheet.create 129 | // @ts-ignore 130 | callPath.node.arguments[0].properties.push(node); 131 | 132 | // Then add StyleSheet.compose call 133 | const newNode = t.variableDeclaration("const", [ 134 | t.variableDeclarator( 135 | t.identifier( 136 | `__${stylesName}${(node.key as Identifier).name}`, 137 | ), 138 | t.callExpression( 139 | t.memberExpression( 140 | t.identifier(StyleSheetName!), 141 | t.identifier("compose"), 142 | ), 143 | [ 144 | // styles.container 145 | t.memberExpression( 146 | t.identifier(stylesName), 147 | t.identifier(ogName), 148 | ), 149 | // styles.__container__$dark 150 | t.memberExpression( 151 | t.identifier(stylesName), 152 | t.identifier((node.key as Identifier).name), 153 | ), 154 | ], 155 | ), 156 | ), 157 | ]); 158 | 159 | programPath.pushContainer("body", [newNode]); 160 | }); 161 | } 162 | }, 163 | }); 164 | 165 | // Short-circuit if no dynamic styles are found 166 | if (Object.values(dynamicStyleMap).every((arr) => arr.length === 0)) 167 | return; 168 | 169 | /** 170 | * Step 3: Import useDarkMode(name?) from react-native-dark. 171 | */ 172 | const useDarkModeName = addNamed( 173 | programPath, 174 | "useDarkMode", 175 | "react-native-dark", 176 | ).name as string; 177 | 178 | /** 179 | * Step #: Find all functions that have JSX that access our style props, and inject hook call 180 | */ 181 | programPath.traverse({ 182 | // @ts-ignore 183 | "FunctionDeclaration|FunctionExpression|ArrowFunctionExpression"( 184 | funcPath: 185 | | babel.NodePath 186 | | babel.NodePath 187 | | babel.NodePath, 188 | ) { 189 | // Before injecting anything, we should find member expressions that need to be modified 190 | const styleCallToMod = [] as { 191 | memPath: NodePath; 192 | darkStyleVarName: string; 193 | }[]; 194 | funcPath.traverse({ 195 | MemberExpression(memPath) { 196 | if ( 197 | t.isIdentifier(memPath.node.object) && 198 | memPath.node.object.name in dynamicStyleMap && 199 | t.isIdentifier(memPath.node.property) && 200 | dynamicStyleMap[memPath.node.object.name].includes( 201 | memPath.node.property.name, 202 | ) 203 | ) { 204 | styleCallToMod.push({ 205 | memPath, 206 | darkStyleVarName: `__${memPath.node.object.name}__${memPath.node.property.name}__$dark`, 207 | }); 208 | } 209 | }, 210 | }); 211 | 212 | if (styleCallToMod.length === 0) return; 213 | 214 | // Inject useDarkMode into function body 215 | const isDarkNode = funcPath.scope.generateUidIdentifier( 216 | "__REACT_NATIVE_DARK_isDark", 217 | ); 218 | const hookDeclaration = t.variableDeclaration("const", [ 219 | t.variableDeclarator( 220 | // @ts-ignore babel types are a bit rough 221 | isDarkNode, 222 | t.callExpression(t.identifier(useDarkModeName), []), 223 | ), 224 | ]); 225 | 226 | // Expressions like `const Footer = () => ;` need to be modded 227 | // to add a block statement 228 | const body = funcPath.get("body"); 229 | if (t.isBlockStatement(body)) { 230 | (body as babel.NodePath).unshiftContainer( 231 | "body", 232 | hookDeclaration, 233 | ); 234 | } else { 235 | (body as NodePath).replaceWith( 236 | t.blockStatement([ 237 | hookDeclaration, 238 | // @ts-ignore 239 | t.returnStatement(body.node), 240 | ]), 241 | ); 242 | } 243 | 244 | styleCallToMod.forEach(({ memPath, darkStyleVarName }) => { 245 | memPath.replaceWith( 246 | t.conditionalExpression( 247 | // @ts-ignore 248 | isDarkNode, 249 | t.identifier(darkStyleVarName), 250 | // @ts-ignore 251 | memPath.node, 252 | ), 253 | ); 254 | }); 255 | }, 256 | }); 257 | }, 258 | }, 259 | }, 260 | }; 261 | } 262 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["src/**/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "declaration": true, 8 | "jsx": "react-native", 9 | "outDir": "./dist", 10 | "strict": true, 11 | "baseUrl": "./src", 12 | "rootDirs": ["./src"], 13 | "skipLibCheck": true, 14 | "downlevelIteration": true, 15 | "allowSyntheticDefaultImports": true 16 | }, 17 | "include": ["src/**/*.ts", "src/**/*.tsx"] 18 | } 19 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | test: { 3 | deps: { 4 | inline: ["react-native"], 5 | }, 6 | }, 7 | }; 8 | --------------------------------------------------------------------------------