diff --git a/example/storybook/stories/BrandConfigProvider/BrandConfigProvider.stories.tsx b/example/storybook/stories/BrandConfigProvider/BrandConfigProvider.stories.tsx new file mode 100644 index 00000000..e5509da8 --- /dev/null +++ b/example/storybook/stories/BrandConfigProvider/BrandConfigProvider.stories.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react-native'; +import { BrandConfigProvider, Theme } from 'src'; +import { ThemeExampleScreen } from './ThemeExampleScreen'; +import * as baseTheme from 'src/components/BrandConfigProvider/theme/base'; + +import { object, color } from '@storybook/addon-knobs'; + +storiesOf('BrandConfigProvider', module) + .add('Basic Theme Colors', () => { + const customColors: Record = {}; + Object.entries(baseTheme.colors).forEach(([key, value]) => { + customColors[key] = color(key, value); + }); + + const theme = new Theme({ colors: customColors }); + + return ( + + + + ); + }) + .add('Default Theme', () => { + const customColors = object('theme.colors', baseTheme.colors); + const customSpacing = object('theme.spacing', baseTheme.spacing); + + const theme = new Theme({ colors: customColors, spacing: customSpacing }); + + return ( + + + + ); + }) + .add('Custom Light Theme', () => { + const customColors = object('theme.colors', { + white: '#FFFFFF', + black: '#000000', + text: '#EFEBE9', + textDim: '#BCAAA4', + border: '#90ee02', + separator: '#D7CEC9', + background: '#3E2723', + primary: '#581aea', + secondary: '#90ee02', + error: '#f01faa', + errorBackground: '#f3bae4', + }); + const customSpacing = object('theme.spacing', baseTheme.spacing); + + const theme = new Theme({ colors: customColors, spacing: customSpacing }); + + return ( + + + + ); + }); diff --git a/example/storybook/stories/BrandConfigProvider/ThemeExampleScreen.tsx b/example/storybook/stories/BrandConfigProvider/ThemeExampleScreen.tsx new file mode 100644 index 00000000..702422ef --- /dev/null +++ b/example/storybook/stories/BrandConfigProvider/ThemeExampleScreen.tsx @@ -0,0 +1,94 @@ +import React from 'react'; + +import { Text, TextStyle, View, ViewStyle } from 'react-native'; +import { useTheme } from 'src/hooks/useTheme'; + +export function ThemeExampleScreen() { + const { theme } = useTheme(); + + const centerView: ViewStyle = { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: theme.colors.background, + }; + + const blackText: TextStyle = { + color: theme.colors.black, + }; + + const whiteText: TextStyle = { + color: theme.colors.white, + }; + + const primaryView: ViewStyle = { + backgroundColor: theme.colors.primary, + }; + + const secondaryView: ViewStyle = { + backgroundColor: theme.colors.secondary, + }; + + const tileView: ViewStyle = { + width: '63%', + margin: theme.spacing.large, + padding: theme.spacing.small, + borderColor: theme.colors.border, + borderWidth: 1, + borderRadius: 8, + }; + + const normalText: TextStyle = { + color: theme.colors.text, + }; + + const dimText: TextStyle = { + color: theme.colors.textDim, + }; + + const separatorView: ViewStyle = { + margin: theme.spacing.large, + width: '80%', + height: 1, + backgroundColor: theme.colors.separator, + }; + + const errorView: ViewStyle = { + margin: theme.spacing.large, + padding: theme.spacing.small, + backgroundColor: theme.colors.errorBackground, + borderColor: theme.colors.error, + borderWidth: 1, + borderRadius: 32, + }; + + const errorText: TextStyle = { + color: theme.colors.error, + }; + + return ( + + + Primary color (white text) + + + + Secondary color (black text) + + + + This is normal text color + + + + This is dim text color + + + + + + Error! + + + ); +} diff --git a/example/storybook/stories/index.ts b/example/storybook/stories/index.ts index 0716e84e..220ab0c5 100644 --- a/example/storybook/stories/index.ts +++ b/example/storybook/stories/index.ts @@ -1,3 +1,4 @@ +import './BrandConfigProvider/BrandConfigProvider.stories'; import './Welcome/Welcome.stories'; import './OAuth.stories'; import './Tile.stories'; diff --git a/src/components/BrandConfigProvider/BrandConfigProvider.tsx b/src/components/BrandConfigProvider/BrandConfigProvider.tsx new file mode 100644 index 00000000..c0a4ccd0 --- /dev/null +++ b/src/components/BrandConfigProvider/BrandConfigProvider.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Theme } from './theme/Theme'; +import { ThemeProvider } from './theme/ThemeProvider'; + +export const BrandConfigContext = React.createContext<{ theme: Theme }>({ + theme: new Theme(), +}); + +interface Props { + theme?: Theme; + children: React.ReactNode; +} + +export function BrandConfigProvider({ theme, children }: Props) { + if (!theme) { + theme = new Theme(); + } + + const context = { + theme, + }; + + return ( + + {children} + + ); +} diff --git a/src/components/BrandConfigProvider/index.ts b/src/components/BrandConfigProvider/index.ts new file mode 100644 index 00000000..7514bb22 --- /dev/null +++ b/src/components/BrandConfigProvider/index.ts @@ -0,0 +1,2 @@ +export { BrandConfigProvider } from './BrandConfigProvider'; +export { Theme } from './theme/Theme'; diff --git a/src/components/BrandConfigProvider/theme/Theme.test.tsx b/src/components/BrandConfigProvider/theme/Theme.test.tsx new file mode 100644 index 00000000..66e0f5aa --- /dev/null +++ b/src/components/BrandConfigProvider/theme/Theme.test.tsx @@ -0,0 +1,33 @@ +import { colors as baseColors, spacing as baseSpacing } from './base'; +import { Theme } from './Theme'; + +describe('Theme', () => { + test('loads defaults', () => { + const theme = new Theme(); + + expect(theme.colors).toStrictEqual(baseColors); + expect(theme.spacing).toStrictEqual(baseSpacing); + }); + + test('merges custom colors with default', () => { + expect(baseColors.background).not.toBe('pink'); + + const customColors = { background: 'pink' }; + + const theme = new Theme({ colors: customColors }); + + expect(theme.colors.background).toBe('pink'); + expect(theme.colors.text).toBe(baseColors.text); + }); + + test('merges custom spacing with default', () => { + expect(baseSpacing.small).not.toBe(42); + + const customSpacing = { small: 42 }; + + const theme = new Theme({ spacing: customSpacing }); + + expect(theme.spacing.small).toBe(42); + expect(theme.spacing.large).toBe(baseSpacing.large); + }); +}); diff --git a/src/components/BrandConfigProvider/theme/Theme.ts b/src/components/BrandConfigProvider/theme/Theme.ts new file mode 100644 index 00000000..4d55b7f1 --- /dev/null +++ b/src/components/BrandConfigProvider/theme/Theme.ts @@ -0,0 +1,24 @@ +import * as baseTheme from './base'; + +interface Props { + colors?: Partial; + spacing?: Partial; +} + +export class Theme { + colors: baseTheme.Colors = baseTheme.colors; + spacing: baseTheme.Spacing = baseTheme.spacing; + + constructor({ colors, spacing }: Props = {}) { + this.mergeColors(colors || {}); + this.mergeSpacing(spacing || {}); + } + + mergeColors(colors: Partial) { + Object.assign(this.colors, colors); + } + + mergeSpacing(spacing: Partial) { + Object.assign(this.spacing, spacing); + } +} diff --git a/src/components/BrandConfigProvider/theme/ThemeProvider.tsx b/src/components/BrandConfigProvider/theme/ThemeProvider.tsx new file mode 100644 index 00000000..9bea2a9c --- /dev/null +++ b/src/components/BrandConfigProvider/theme/ThemeProvider.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Theme } from './Theme'; + +interface Props { + theme: Theme; + children: React.ReactNode; +} + +export function ThemeProvider({ theme, children }: Props) { + return ( + {children} + ); +} + +export const ThemeContext = React.createContext<{ theme: Theme }>({ + theme: new Theme(), +}); diff --git a/src/components/BrandConfigProvider/theme/base/colors.ts b/src/components/BrandConfigProvider/theme/base/colors.ts new file mode 100644 index 00000000..6a531b15 --- /dev/null +++ b/src/components/BrandConfigProvider/theme/base/colors.ts @@ -0,0 +1,32 @@ +const palette = { + neutral200: '#F4F2F1', + neutral300: '#D7CEC9', + neutral400: '#B6ACA6', + neutral600: '#564E4A', + neutral800: '#191015', + + blueGray200: '#B0BEC5', + blueGray800: '#37474F', + + error100: '#F2D6CD', + error500: '#C03403', +}; + +export const colors = { + white: '#FFFFFF', + black: '#000000', + + text: palette.neutral800, + textDim: palette.neutral600, + border: palette.neutral400, + separator: palette.neutral300, + background: palette.neutral200, + + primary: palette.blueGray800, + secondary: palette.blueGray200, + + error: palette.error500, + errorBackground: palette.error100, +}; + +export type Colors = Partial; diff --git a/src/theme/index.ts b/src/components/BrandConfigProvider/theme/base/index.ts similarity index 100% rename from src/theme/index.ts rename to src/components/BrandConfigProvider/theme/base/index.ts diff --git a/src/theme/spacing.ts b/src/components/BrandConfigProvider/theme/base/spacing.ts similarity index 72% rename from src/theme/spacing.ts rename to src/components/BrandConfigProvider/theme/base/spacing.ts index 042651f0..ca2d2383 100644 --- a/src/theme/spacing.ts +++ b/src/components/BrandConfigProvider/theme/base/spacing.ts @@ -8,6 +8,6 @@ export const spacing = { extraLarge: 32, huge: 48, massive: 64, -} as const; +}; -export type Spacing = keyof typeof spacing; +export type Spacing = typeof spacing; diff --git a/src/components/Text.test.tsx b/src/components/Text.test.tsx deleted file mode 100644 index edeb49b7..00000000 --- a/src/components/Text.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react-native'; -import { Text } from './Text'; -import { BrandConfig, BrandConfigProvider } from '../hooks/useBrandConfig'; -import { colors } from '../theme'; - -describe('Without configuration', () => { - test('Renders text with default style (baseTheme.colors.text)', () => { - render(Hello World!); - - expect(screen.getByText('Hello World!')).toBeDefined(); - expect(screen.toJSON()).toMatchInlineSnapshot(textSnapshot(colors.text)); - }); -}); - -describe('With configuration', () => { - const customAppStyles: BrandConfig['styles'] = { - Text: { - base: { color: 'green' }, - heading: { color: 'blue', fontWeight: 'bold' }, - }, - }; - - test('Renders text with configured style', () => { - render( - - Hello World! - , - ); - - expect(screen.toJSON()).toMatchInlineSnapshot(textSnapshot('green')); - }); - - test('Accepts a variant', () => { - render( - - Hello World! - , - ); - - expect(screen.toJSON()).toMatchInlineSnapshot(textBoldSnapshot('blue')); - }); - - test('Style object that takes precedence over variant styling', () => { - render( - - - Hello World! - - , - ); - - expect(screen.toJSON()).toMatchInlineSnapshot(textBoldSnapshot('black')); - }); -}); - -const textSnapshot = (color: string) => ` - - Hello World! - -`; - -const textBoldSnapshot = (color: string) => ` - - Hello World! - -`; diff --git a/src/components/Text.tsx b/src/components/Text.tsx deleted file mode 100644 index ece60bca..00000000 --- a/src/components/Text.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { Text as RNText, TextStyle, StyleSheet } from 'react-native'; -import { useBrandConfig } from '../hooks/useBrandConfig'; -import { colors } from '../theme'; - -interface Props { - variant?: Variant; - style?: TextStyle; - children: React.ReactNode; -} - -export function Text({ variant = 'base', style, children }: Props) { - const { styles } = useBrandConfig(); - - const mergedStyles = StyleSheet.flatten([ - defaultTextStyles.base, - styles.Text?.base, - styles.Text?.[variant], - style, - ]); - - return {children}; -} - -export interface TextStyles { - base?: TextStyle; - body?: TextStyle; - heading?: TextStyle; - subHeading?: TextStyle; -} - -type Variant = keyof TextStyles; - -export const defaultTextStyles: TextStyles = { - base: { - color: colors.text, - }, -}; diff --git a/src/components/index.ts b/src/components/index.ts index 11826b78..73c73d59 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,5 +1,5 @@ export * from './ActivityIndicatorView'; +export * from './BrandConfigProvider'; export * from './OAuthLoginButton'; export * from './OAuthLogoutButton'; export * from './tiles/Tile'; -export * from './Text'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 01906f7c..def58eb9 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -3,9 +3,9 @@ export * from './useActiveAccount'; export * from './useActiveProject'; export * from './useAppConfig'; export * from './useAuth'; -export * from './useBrandConfig'; export * from './useHttpClient'; export * from './useMe'; export * from './useOAuthFlow'; export * from './useSubjectProjects'; +export * from './useTheme'; export * from './useUser'; diff --git a/src/hooks/useBrandConfig.tsx b/src/hooks/useBrandConfig.tsx deleted file mode 100644 index 54d63ae2..00000000 --- a/src/hooks/useBrandConfig.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import { StyleSheet } from 'react-native'; -import { TextStyles, defaultTextStyles } from '../components/Text'; -import * as theme from '../theme'; - -export interface BrandConfig { - styles: { - Text?: TextStyles | undefined; - }; - theme: {}; -} - -const BrandConfigContext = React.createContext({ - styles: { - Text: defaultTextStyles, - }, - theme, -}); - -interface Props { - styles?: BrandConfig['styles']; - children: React.ReactNode; -} - -export function BrandConfigProvider({ styles, children }: Props) { - const defaultStyles = { - Text: defaultTextStyles, - }; - - const mergedStyles = StyleSheet.flatten([ - defaultStyles, - styles, - ]) as BrandConfig['styles']; // TODO: Better typing - - const context = { - styles: mergedStyles, - theme, - }; - - return ( - - {children} - - ); -} - -export const useBrandConfig = () => React.useContext(BrandConfigContext); diff --git a/src/hooks/useTheme.test.tsx b/src/hooks/useTheme.test.tsx new file mode 100644 index 00000000..e5681047 --- /dev/null +++ b/src/hooks/useTheme.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-native'; +import { BrandConfigProvider } from '../components/BrandConfigProvider/BrandConfigProvider'; +import { Theme } from '../components/BrandConfigProvider/theme/Theme'; +import * as baseTheme from '../components/BrandConfigProvider/theme/base'; +import { useTheme } from './useTheme'; + +const renderHookInContext = async (theme?: Theme) => { + return renderHook(() => useTheme(), { + wrapper: ({ children }) => ( + {children} + ), + }); +}; + +test('returns theme', async () => { + const { result } = await renderHookInContext(); + + expect(result.current.theme).toBeInstanceOf(Theme); + expect(result.current.theme).toEqual(baseTheme); +}); + +test('returns custom theme merged with base theme', async () => { + const customTheme = new Theme({ colors: { text: 'pink' } }); + const { + result: { + current: { theme }, + }, + } = await renderHookInContext(customTheme); + + expect(theme.colors.text).toBe('pink'); + expect(theme.colors.background).toBe(baseTheme.colors.background); +}); diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 00000000..ae6e95bd --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,4 @@ +import React from 'react'; +import { ThemeContext } from '../components/BrandConfigProvider/theme/ThemeProvider'; + +export const useTheme = () => React.useContext(ThemeContext); diff --git a/src/theme/colors.ts b/src/theme/colors.ts deleted file mode 100644 index 126ee46a..00000000 --- a/src/theme/colors.ts +++ /dev/null @@ -1,65 +0,0 @@ -// TODO: Replace with our real default colors -const palette = { - white: '#FFFFFF', - black: '#000000', - - neutral200: '#F4F2F1', - neutral300: '#D7CEC9', - neutral400: '#B6ACA6', - neutral600: '#564E4A', - neutral800: '#191015', - - primary500: '#C76542', - - secondary300: '#9196B9', - - accent300: '#FDD495', - - error100: '#F2D6CD', - error500: '#C03403', -} as const; - -export const colors = { - /** - * Prefer semantic names over use of the palette directly - */ - palette, - /** - * A helper for making something see-thru - */ - transparent: 'rgba(0, 0, 0, 0)', - /** - * The default text color in many components - */ - text: palette.neutral800, - /** - * Secondary text information - */ - textDim: palette.neutral600, - /** - * The default color of the screen background - */ - background: palette.neutral200, - /** - * The default border color - */ - border: palette.neutral400, - /** - * The main tinting color - */ - tint: palette.primary500, - /** - * A subtle color used for lines - */ - separator: palette.neutral300, - /** - * Error messages - */ - error: palette.error500, - /** - * Error Background - */ - errorBackground: palette.error100, -}; - -export type Colors = typeof colors;