Skip to content

Commit

Permalink
refactor(theme): preparing for multi-theme + sub vars
Browse files Browse the repository at this point in the history
  • Loading branch information
stackchain committed Apr 15, 2024
1 parent c025e4e commit e57f9ee
Show file tree
Hide file tree
Showing 35 changed files with 1,048 additions and 1,374 deletions.
2 changes: 1 addition & 1 deletion packages/theme/babel.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module.exports = {
presets: ["@babel/preset-env", "module:metro-react-native-babel-preset"],
presets: ["module:metro-react-native-babel-preset"],
};
5 changes: 5 additions & 0 deletions packages/theme/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
)

jest.setTimeout(30000)
1 change: 0 additions & 1 deletion packages/theme/jest.setup.ts

This file was deleted.

15 changes: 6 additions & 9 deletions packages/theme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"module": "lib/module/index",
"source": "src/index",
"browser": "lib/module/index",
"types": "lib/typescript/src/index.d.ts",
"types": "lib/typescript/index.d.ts",
"files": [
"src",
"lib",
Expand All @@ -40,7 +40,7 @@
"!**/.*"
],
"scripts": {
"build": "yarn tsc && yarn lint && yarn clean && bob build",
"build": "yarn tsc && yarn lint && yarn test --ci --silent && yarn clean && bob build",
"build:release": "yarn build && yarn flow",
"clean": "del-cli lib",
"dgraph": "depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg",
Expand Down Expand Up @@ -96,14 +96,15 @@
"node_modules/",
"lib/",
"babel.config.js",
"jest.setup.ts",
"jest.setup.js",
"coverage/"
],
"jest": {
"collectCoverage": true,
"collectCoverageFrom": [
"src/**/*.{js,jsx,ts,tsx}",
"!src/**/*.d.ts"
"!src/**/*.d.ts",
"!src/storybook/**"
],
"coverageReporters": [
"text-summary",
Expand All @@ -123,18 +124,14 @@
],
"preset": "react-native",
"setupFiles": [
"<rootDir>/jest.setup.ts"
"<rootDir>/jest.setup.js"
]
},
"devDependencies": {
"@babel/preset-env": "^7.23.3",
"@babel/preset-typescript": "^7.23.2",
"@commitlint/config-conventional": "^17.0.2",
"@react-native-async-storage/async-storage": "^1.19.3",
"@react-native-community/eslint-config": "^3.0.2",
"@release-it/conventional-changelog": "^5.0.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/react-native": "^12.3.0",
"@tsconfig/react-native": "^3.0.3",
Expand Down
32 changes: 32 additions & 0 deletions packages/theme/src/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Yoroi Theme

## Usage

It uses "t-shirt" sizes e.g `sm`, `md`, `lg`, etc.

Naming conventions follow Tailwind, delimited by `_` instead of `-` to
enable object access.

### Atoms

The style definitions that "match" Tailwind CSS selectors.

```tsx
import {atoms as a} from '@yoroi/theme'

<View style={[a.flex_1]} />
```

### Theme

The palette definition, prefer to use `useThemeColor` most of the time.
The `useTheme` was designed to manage the theme and not to consume it.
Sub vars should be consumed from the theme always, everything else should be consumed from atoms directly.

```tsx
import {atoms as a, useThemeColor} from '@yoroi/theme'

const {gray_500} = useThemeColor()

<View style={[a.flex_1, {backgroundColor: gray_500}]} />
```
86 changes: 86 additions & 0 deletions packages/theme/src/ThemeProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react'
import {render, fireEvent} from '@testing-library/react-native'
import {Button, Text} from 'react-native'

import {ThemeProvider, useTheme, useThemeColor} from './ThemeProvider'
import {ThemeStorage} from './types'
import {ErrorBoundary} from '@yoroi/common'

describe('ThemeProvider', () => {
const mockStorage: ThemeStorage = {
key: 'theme-name',
save: jest.fn(),
read: jest.fn(),
}

it('should render children', () => {
const {getByText} = render(
<ThemeProvider storage={mockStorage}>
<Text>Test</Text>
</ThemeProvider>,
)

expect(getByText('Test')).toBeTruthy()
})

it('should provide the theme context', () => {
const TestComponent = () => {
const theme = useTheme()
return <Text>{theme.name}</Text>
}

const {getByText} = render(
<ThemeProvider storage={mockStorage}>
<TestComponent />
</ThemeProvider>,
)

expect(getByText('default-light')).toBeTruthy()
})

it('should update the theme when selectThemeName is called', () => {
const TestComponent = () => {
const theme = useTheme()
const color = useThemeColor()
return (
<>
<Text>{theme.name}</Text>
<Button
onPress={() => theme.selectThemeName('default-dark')}
title="Change Theme"
/>
<Text>{color.black_static}</Text>
</>
)
}

const {getByText} = render(
<ThemeProvider storage={mockStorage}>
<TestComponent />
</ThemeProvider>,
)

expect(getByText('default-light')).toBeTruthy()

expect(getByText('#000000')).toBeTruthy()

fireEvent.press(getByText('Change Theme'))

expect(getByText('default-dark')).toBeTruthy()
})

it('should throw an error when useTheme is called without a provider', () => {
const TestComponent = () => {
useTheme()
return null
}

const {getByTestId} = render(
<ErrorBoundary>
<TestComponent />
</ErrorBoundary>,
)

expect(getByTestId('hasError')).toBeTruthy()
})
})
71 changes: 40 additions & 31 deletions packages/theme/src/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,62 @@
import React from 'react'
import {useColorScheme} from 'react-native'

import {darkTheme} from './themes/darkTheme'
import {lightTheme} from './themes/lightTheme'
import {Theme} from './types'
import {Palette, SupportedThemes, Theme, ThemeStorage} from './types'
import {defaultLightTheme} from './themes/default-light'
import {defaultDarkTheme} from './themes/default-dark'
import {detectTheme} from './helpers/detect-theme'

const ThemeContext = React.createContext<undefined | ThemeContext>(undefined)
export const ThemeProvider = ({children}: {children: React.ReactNode}) => {
const [colorScheme, setColorScheme] = React.useState<'light' | 'dark'>(
'light',
)

const selectColorScheme = React.useCallback(
(themeColor: 'light' | 'dark') => {
setColorScheme(themeColor)
},
[],
)
export const ThemeProvider = ({
children,
storage,
}: {
children: React.ReactNode
storage: ThemeStorage
}) => {
const colorScheme = useColorScheme()
const [themeName, setThemeName] = React.useState<
Exclude<SupportedThemes, 'system'>
>(detectTheme(colorScheme, storage.read() ?? 'system'))

const value = React.useMemo(
() => ({
theme: themes[colorScheme],
colorScheme,
selectColorScheme,
isLight: colorScheme === 'light',
isDark: colorScheme === 'dark',
name: themes[themeName].name,
color: themes[themeName].color,

selectThemeName: (newTheme: SupportedThemes) => {
const detectedTheme = detectTheme(colorScheme, newTheme)
setThemeName(detectedTheme)
storage.save(newTheme)
},

isLight: themes[themeName].base === 'light',
isDark: themes[themeName].base === 'dark',
}),
[colorScheme, selectColorScheme],
[colorScheme, storage, themeName],
)

return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}

export const useTheme = () =>
React.useContext(ThemeContext) || missingProvider()
React.useContext(ThemeContext) ?? missingProvider()

const missingProvider = () => {
throw new Error('ThemeProvider is missing')
}
export const useThemeColor = () => useTheme().color

type ColorSchemeOption = 'light' | 'dark'
type ThemeContext = {
theme: Theme
colorScheme: ColorSchemeOption
selectColorScheme: (colorTheme: ColorSchemeOption) => void
name: SupportedThemes
color: Palette
selectThemeName: (name: SupportedThemes) => void
isLight: boolean
isDark: boolean
}

const themes: Record<'light' | 'dark', Theme> = {
light: lightTheme,
dark: darkTheme,
const themes: Record<Exclude<SupportedThemes, 'system'>, Theme> = {
['default-light']: defaultLightTheme,
['default-dark']: defaultDarkTheme,
}

const missingProvider = () => {
throw new Error('ThemeProvider is missing')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {themeStorageMaker} from './theme-storage-maker'
import {SupportedThemes} from '../../types'

describe('themeStorageMaker', () => {
const mockStorage = {
setItem: jest.fn(),
getItem: jest.fn(),
} as any

beforeEach(() => {
jest.clearAllMocks()
})

it('should save the theme name to storage', () => {
const themeStorage = themeStorageMaker({storage: mockStorage})
const themeName: SupportedThemes = 'default-dark'

themeStorage.save(themeName)

expect(mockStorage.setItem).toHaveBeenCalledWith('theme-name', themeName)
})

it('should read the theme name from storage', () => {
const themeStorage = themeStorageMaker({storage: mockStorage})
const themeName: SupportedThemes = 'default-light'
mockStorage.getItem.mockReturnValue(themeName)

const result = themeStorage.read()

expect(mockStorage.getItem).toHaveBeenCalledWith('theme-name')
expect(result).toBe(themeName)
})

it('should return the correct key', () => {
const themeStorage = themeStorageMaker({storage: mockStorage})

const result = themeStorage.key

expect(result).toBe('theme-name')
})
})
23 changes: 23 additions & 0 deletions packages/theme/src/adapters/mmkv-storage/theme-storage-maker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {App} from '@yoroi/types'
import {freeze} from 'immer'

import {SupportedThemes, ThemeStorage} from '../../types'

const themeNameKey = 'theme-name'

export const themeStorageMaker = ({
storage,
}: {
storage: App.ObservableStorage<false>
}): ThemeStorage => {
const save = (name: SupportedThemes) =>
storage.setItem<SupportedThemes>(themeNameKey, name)

const read = () => storage.getItem<SupportedThemes>(themeNameKey)

return freeze({
save,
read,
key: themeNameKey,
})
}
7 changes: 7 additions & 0 deletions packages/theme/src/atoms/atoms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {atoms} from './atoms'

describe('atoms', () => {
it('should be defined', () => {
expect(atoms).toBeDefined()
})
})
Loading

0 comments on commit e57f9ee

Please sign in to comment.