diff --git a/src/view/nova/index.tsx b/src/view/nova/index.tsx new file mode 100644 index 0000000000..8123f0d586 --- /dev/null +++ b/src/view/nova/index.tsx @@ -0,0 +1,102 @@ +import {View, Text as RNText} from 'react-native' + +import {createSystem} from './lib/system' +import {light, dark} from './themes' +import {web} from './lib/utils' + +export * from './lib/utils' + +const { + ThemeProvider, + useTheme, + useTokens, + useBreakpoints, + useStyle, + useStyles, + styled, +} = createSystem({ + light, + dark, +}) + +export { + ThemeProvider, + useTheme, + useTokens, + useBreakpoints, + useStyle, + useStyles, + styled, +} + +export const Box = styled(View) +export const Text = styled(RNText, { + color: 'l8', + fontSize: 's', +}) + +/** + * @see https://necolas.github.io/react-native-web/docs/accessibility/#semantic-html + * @see https://docs.expo.dev/develop/user-interface/fonts/ + */ +export const H1 = styled(RNText, { + color: 'l8', + fontSize: 'l', + gtMobile: { + fontSize: 'xl', + }, + ...web({ + role: 'heading', + 'aria-level': 1, + }), +}) +export const H2 = styled(RNText, { + color: 'l8', + fontSize: 'm', + gtMobile: { + fontSize: 'l', + }, + ...web({ + role: 'heading', + 'aria-level': 2, + }), +}) +export const H3 = styled(RNText, { + color: 'l8', + fontSize: 'm', + ...web({ + role: 'heading', + 'aria-level': 3, + }), +}) +export const H4 = styled(RNText, { + color: 'l8', + fontSize: 's', + ...web({ + role: 'heading', + 'aria-level': 4, + }), +}) +export const H5 = styled(RNText, { + color: 'l8', + fontSize: 'xs', + ...web({ + role: 'heading', + 'aria-level': 5, + }), +}) +export const H6 = styled(RNText, { + color: 'l8', + fontSize: 'xxs', + ...web({ + role: 'heading', + 'aria-level': 6, + }), +}) +export const P = styled(RNText, { + color: 'l8', + fontSize: 's', + ...web({ + role: 'paragraph', + }), +}) diff --git a/src/view/nova/lib/__tests__/system.test.tsx b/src/view/nova/lib/__tests__/system.test.tsx new file mode 100644 index 0000000000..355fdf946d --- /dev/null +++ b/src/view/nova/lib/__tests__/system.test.tsx @@ -0,0 +1,147 @@ +import React from 'react' +import type {ViewProps} from 'react-native' +import TestRenderer from 'react-test-renderer' +import {jest, test, expect} from '@jest/globals' + +import {createTheme} from '../theme' +import {createSystem} from '../system' + +jest.mock('react-native', () => ({ + Dimensions: { + get: () => ({width: 0, height: 0}), + addEventListener() {}, + }, + StyleSheet: { + create(style: any) { + return style + }, + }, +})) + +const theme = createTheme({ + tokens: { + space: { + s: 4, + m: 8, + l: 16, + }, + color: { + theme: 'tomato', + text: 'black', + }, + }, + properties: { + px: ['paddingLeft', 'paddingRight'], + }, + macros: { + caps: (_: boolean) => ({textTransform: 'uppercase'}), + }, + breakpoints: { + gtPhone: 640, + gtTablet: 1024, + }, +}) + +const {ThemeProvider, useTheme, useStyle, useStyles, styled} = createSystem({ + theme, +}) + +function Root(props: React.PropsWithChildren<{}>) { + return {props.children} +} + +test('useTheme', () => { + function Component() { + const theme = useTheme() + return <>{JSON.stringify(theme)} + } + + const dom = TestRenderer.create( + + + , + ) + const json = JSON.parse(dom.toJSON() as unknown as string) + + expect(json.themeName).toEqual('theme') + expect(json.theme).toBeTruthy() + expect(Object.keys(json.themes)).toEqual(['theme']) +}) + +test('useStyle', () => { + function Component() { + const style = useStyle({color: 'red'}) + return <>{JSON.stringify(style)} + } + + const dom = TestRenderer.create( + + + , + ) + const json = JSON.parse(dom.toJSON() as unknown as string) + + expect(json.color).toEqual('red') +}) + +test('useStyles', () => { + function Component() { + const {text} = useStyles({ + text: {color: 'red'}, + }) + return <>{JSON.stringify(text)} + } + + const dom = TestRenderer.create( + + + , + ) + const json = JSON.parse(dom.toJSON() as unknown as string) + + expect(json.color).toEqual('red') +}) + +test('styled', () => { + function Component(props: ViewProps) { + return <>{JSON.stringify(props)} + } + const Box = styled(Component, { + color: 'theme', + }) + + const dom = TestRenderer.create( + + + , + ) + const json = JSON.parse(dom.toJSON() as unknown as string) + + expect(json.style).toEqual([{color: 'red'}, {padding: 10}]) + expect(json.id).toEqual('foo') +}) + +test('types', () => { + function Component(props: ViewProps & {foo: boolean}) { + // @ts-expect-error + const {foo} = useStyles({ + text: {color: 'red'}, + }) + // just need a way to use `foo` + return {JSON.stringify(props)} + } + const Box = styled(Component, { + id: true, + // @ts-expect-error + color: true, + }) + + TestRenderer.create( + + {/* @ts-expect-error */} + + + + , + ) +}) diff --git a/src/view/nova/lib/__tests__/theme.test.ts b/src/view/nova/lib/__tests__/theme.test.ts new file mode 100644 index 0000000000..888b226598 --- /dev/null +++ b/src/view/nova/lib/__tests__/theme.test.ts @@ -0,0 +1,278 @@ +import type {ViewProps} from 'react-native' +import {describe, test, expect} from '@jest/globals' + +import {createTheme} from '../theme' + +const theme = createTheme({ + tokens: { + space: { + s: 4, + m: 8, + l: 16, + }, + color: { + theme: 'tomato', + text: 'black', + }, + }, + properties: { + px: ['paddingLeft', 'paddingRight'], + }, + macros: { + caps: (_: boolean) => ({textTransform: 'uppercase'}), + }, + breakpoints: { + gtPhone: 640, + gtTablet: 1024, + }, +}) + +describe('non-style props', () => { + test('e.g. className', () => { + const {styles, props} = theme.style( + { + id: 'foo', + }, + [], + ) + + expect(styles).toEqual({}) + expect(props).toEqual({ + id: 'foo', + }) + }) +}) + +describe('properties', () => { + test('standard', () => { + const {styles} = theme.style( + { + color: 'blue', + }, + [], + ) + + expect(styles).toEqual({ + color: 'blue', + }) + }) + + test('custom', () => { + const {styles} = theme.style( + { + px: 20, + }, + [], + ) + + expect(styles).toEqual({ + paddingLeft: 20, + paddingRight: 20, + }) + }) + + test('undefined values', () => { + const {styles} = theme.style( + { + px: undefined, + }, + [], + ) + + expect(styles).toEqual({}) + }) +}) + +describe('tokens', () => { + test('match', () => { + const {styles} = theme.style( + { + color: 'theme', + paddingTop: 's', + }, + [], + ) + + expect(styles).toEqual({ + color: 'tomato', + paddingTop: 4, + }) + }) + + test('no match', () => { + const {styles} = theme.style( + { + color: 'blue', + paddingTop: 20, + }, + [], + ) + + expect(styles).toEqual({ + color: 'blue', + paddingTop: 20, + }) + }) +}) + +describe('macros', () => { + test('truthy', () => { + const {styles} = theme.style( + { + caps: true, + }, + [], + ) + + expect(styles).toEqual({ + textTransform: 'uppercase', + }) + }) + + test('falsy', () => { + const {styles} = theme.style( + { + caps: false, + }, + [], + ) + + expect(styles).toEqual({}) + }) +}) + +describe('breakpoints', () => { + test('applies', () => { + const {styles} = theme.style( + { + paddingTop: 's', + gtPhone: { + paddingTop: 'm', + }, + }, + ['gtPhone'], + ) + + expect(styles).toEqual({ + paddingTop: 8, + }) + }) + + test('not applies', () => { + const {styles} = theme.style( + { + paddingTop: 's', + gtPhone: { + paddingTop: 'm', + }, + }, + [], + ) + + expect(styles).toEqual({ + paddingTop: 4, + }) + }) + + test('applies in order, asc', () => { + const {styles} = theme.style( + { + paddingTop: 's', + gtPhone: { + paddingTop: 'm', + }, + gtTablet: { + paddingTop: 'l', + }, + }, + ['gtTablet', 'gtPhone'], + ) + + expect(styles).toEqual({ + paddingTop: 16, + }) + }) + + test('applies in order even if defined in reverse', () => { + const theme = createTheme({ + tokens: { + space: { + s: 4, + }, + }, + breakpoints: { + // reverse order + gtTablet: 1024, + gtPhone: 640, + }, + }) + + const {styles} = theme.style( + { + paddingTop: 20, + gtTablet: { + paddingTop: 30, + }, + gtPhone: { + paddingTop: 25, + }, + }, + ['gtTablet', 'gtPhone'], + ) + + expect(styles).toEqual({ + paddingTop: 30, + }) + }) +}) + +describe('utils', () => { + test('getActiveBreakpoints', () => { + const breakpoints = theme.getActiveBreakpoints({width: 1000}) + expect(breakpoints).toEqual({ + current: 'gtPhone', + active: ['gtPhone'], + }) + }) +}) + +describe('types', () => { + const theme = createTheme({ + tokens: { + space: { + s: 4, + }, + color: { + theme: 'tomato', + }, + }, + properties: { + px: ['paddingLeft', 'paddingRight'], + }, + macros: { + caps: (_: boolean) => ({textTransform: 'uppercase'}), + }, + breakpoints: { + gtPhone: 640, + }, + }) + + test('style', () => { + theme.style( + { + // @ts-expect-error + color: false, + px: 20, + // @ts-expect-error + caps: 1, + }, + [], + ) + + theme.style( + {}, + // @ts-expect-error + ['taco'], + ) + }) +}) diff --git a/src/view/nova/lib/__tests__/utils.test.ts b/src/view/nova/lib/__tests__/utils.test.ts new file mode 100644 index 0000000000..a12b6aac29 --- /dev/null +++ b/src/view/nova/lib/__tests__/utils.test.ts @@ -0,0 +1,24 @@ +import {test, expect} from '@jest/globals' + +import * as utils from '../utils' + +test(`web`, async () => { + const result = utils.web('foo') + expect(result).toBe(undefined) +}) + +test(`ios`, async () => { + const result = utils.ios('foo') + // TODO will fail in CI + expect(result).toBe('foo') +}) + +test(`android`, async () => { + const result = utils.android('foo') + expect(result).toBe(undefined) +}) + +test(`native`, async () => { + const result = utils.native('foo') + expect(result).toBe('foo') +}) diff --git a/src/view/nova/lib/system.tsx b/src/view/nova/lib/system.tsx new file mode 100644 index 0000000000..adaf7e2a8e --- /dev/null +++ b/src/view/nova/lib/system.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import {Dimensions} from 'react-native' + +import { + Theme, + ResponsiveStyles, + Tokens, + Properties, + Macros, + Breakpoints, +} from './theme' + +type ThemeConfig< + T extends Tokens, + P extends Properties, + M extends Macros, + B extends Breakpoints, +> = { + [key: string]: Theme +} + +export function createSystem< + T extends Tokens, + P extends Properties, + M extends Macros, + B extends Breakpoints, +>(themes: ThemeConfig) { + const defaultTheme = Object.values(themes)[0] + + type SystemTheme = Theme + type ResponsiveStyleProps = ResponsiveStyles + type StylesAndProps = Parameters>[0] + type DebugProps = { + /** + * Debug mode will log the styles and props to the console + */ + debug?: boolean + } + + type ThemeName = keyof typeof themes + const Context = React.createContext<{ + themeName: ThemeName + theme: SystemTheme + themes: { + [key in ThemeName]: SystemTheme + } + }>({ + themeName: Object.keys(themes)[0], + theme: defaultTheme, + themes, + }) + + const ThemeProvider = ({ + children, + theme, + }: React.PropsWithChildren<{theme: ThemeName}>) => ( + + {children} + + ) + + function useTheme() { + return React.useContext(Context) + } + + function useTokens() { + return React.useContext(Context).theme.config.tokens + } + + function useBreakpoints() { + const {theme} = useTheme() + const [breakpoints, setBreakpoints] = React.useState( + theme.getActiveBreakpoints({width: Dimensions.get('window').width}), + ) + + React.useEffect(() => { + const listener = Dimensions.addEventListener('change', ({window}) => { + const bp = theme.getActiveBreakpoints({width: window.width}) + if (bp.current !== breakpoints.current) setBreakpoints(bp) + }) + + return () => { + listener.remove() + } + }, [breakpoints, theme]) + + return breakpoints + } + + function useStyle(props: ResponsiveStyleProps) { + const {theme} = useTheme() + const breakpoints = useBreakpoints() + return theme.style(props as StylesAndProps, breakpoints.active).styles + } + + function useStyles>( + styles: O, + ): { + [Name in keyof O]: ReturnType['styles'] + } { + const {theme} = useTheme() + const breakpoints = useBreakpoints() + return React.useMemo(() => { + return Object.entries(styles).reduce((acc, [key, style]) => { + acc[key as keyof O] = theme.style( + style as StylesAndProps, + breakpoints.active, + ).styles + return acc + }, {} as {[Name in keyof O]: ReturnType['styles']}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [styles, breakpoints.current, theme]) + } + + function styled>( + Component: React.ComponentType, + defaultProps: ResponsiveStyleProps = {}, + ) { + const comp = React.forwardRef< + Props, + Props & + ResponsiveStyleProps & + DebugProps & {children?: React.ReactNode | React.ReactNodeArray} + >((props, ref) => { + const {theme} = useTheme() + const breakpoints = useBreakpoints() + const {styles, props: rest} = theme.style( + { + ...defaultProps, + ...props, + }, + breakpoints.active, + ) + if (props.debug) console.log({styles, props: rest}) + return + }) + + comp.displayName = Component.displayName || 'NovaComponent' + + return comp + } + + return { + Context, + ThemeProvider, + useTheme, + useTokens, + useBreakpoints, + useStyle, + useStyles, + styled, + } +} diff --git a/src/view/nova/lib/theme.ts b/src/view/nova/lib/theme.ts new file mode 100644 index 0000000000..db6776a8bf --- /dev/null +++ b/src/view/nova/lib/theme.ts @@ -0,0 +1,394 @@ +import type { + ViewStyle, + TextStyle, + ImageStyle, + DimensionValue, + ColorValue, +} from 'react-native' +import {StyleSheet} from 'react-native' + +type StyleObject = ViewStyle & TextStyle & ImageStyle +type StyleObjectProperties = keyof StyleObject +type ColorProperties = + | 'color' + | 'backgroundColor' + | 'borderColor' + | 'borderTopColor' + | 'borderRightColor' + | 'borderBottomColor' + | 'borderLeftColor' +type DimensionProperties = + | 'padding' + | 'paddingTop' + | 'paddingBottom' + | 'paddingLeft' + | 'paddingRight' + | 'paddingVertical' + | 'paddingHorizontal' + | 'margin' + | 'marginTop' + | 'marginBottom' + | 'marginLeft' + | 'marginRight' + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'gap' + | 'rowGap' + | 'columnGap' + +export type Tokens = { + space?: DimensionValue[] | Record +} & { + [Property in StyleObjectProperties]?: Record< + string, + Required[Property] + > +} +export type Properties = Record> +export type Macros = Record< + string, + (value: any, tokens: T) => StyleObject +> +export type Breakpoints = { + [Breakpoint: string]: number +} + +export type Styles< + T extends Tokens, + P extends Properties, + M extends Macros, +> = { + [Property in ColorProperties]?: + | keyof T['color'] + | Omit +} & { + [Property in DimensionProperties]?: keyof T['space'] | DimensionValue +} & { + [Property in Exclude< + StyleObjectProperties, + ColorProperties | DimensionProperties + >]?: keyof T[Property] | StyleObject[Property] +} & { + // this empty P is to avoid circular references back to Properties type + [Property in keyof P]?: Styles[P[Property][0]] +} & { + [Property in keyof M]?: M[Property] extends ( + value: infer Value, + tokens: T, + ) => StyleObject + ? Value + : never +} + +export type ResponsiveStyles< + T extends Tokens, + P extends Properties, + M extends Macros, + B extends Breakpoints, +> = + | Styles + | { + [Breakpoint in keyof B]?: Styles + } + +export type Theme< + T extends Tokens, + P extends Properties, + M extends Macros, + B extends Breakpoints, +> = { + config: { + tokens: T + properties: P + macros: M + breakpoints: B + } + style: ( + props: ResponsiveStyles & Omit, + breakpoints?: (keyof B)[], + ) => { + styles: StyleObject + props: Props + } + getActiveBreakpoints: ({width}: {width: number}) => { + active: (keyof B)[] + current: keyof B + } +} + +const specialTokenMapping = { + color: 'color', + backgroundColor: 'color', + borderColor: 'color', + borderTopColor: 'color', + borderRightColor: 'color', + borderBottomColor: 'color', + borderLeftColor: 'color', + padding: 'space', + paddingTop: 'space', + paddingBottom: 'space', + paddingLeft: 'space', + paddingRight: 'space', + paddingVertical: 'space', + paddingHorizontal: 'space', + margin: 'space', + marginTop: 'space', + marginBottom: 'space', + marginLeft: 'space', + marginRight: 'space', + top: 'space', + bottom: 'space', + left: 'space', + right: 'space', + gap: 'space', + rowGap: 'space', + columnGap: 'space', +} + +const propertyMapping: Properties = { + // FlexStyle + alignContent: ['alignContent'], + alignItems: ['alignItems'], + alignSelf: ['alignSelf'], + aspectRatio: ['aspectRatio'], + borderBottomWidth: ['borderBottomWidth'], + borderEndWidth: ['borderEndWidth'], + borderLeftWidth: ['borderLeftWidth'], + borderRightWidth: ['borderRightWidth'], + borderStartWidth: ['borderStartWidth'], + borderTopWidth: ['borderTopWidth'], + borderWidth: ['borderWidth'], + bottom: ['bottom'], + display: ['display'], + end: ['end'], + flex: ['flex'], + flexBasis: ['flexBasis'], + flexDirection: ['flexDirection'], + rowGap: ['rowGap'], + gap: ['gap'], + columnGap: ['columnGap'], + flexGrow: ['flexGrow'], + flexShrink: ['flexShrink'], + flexWrap: ['flexWrap'], + height: ['height'], + justifyContent: ['justifyContent'], + left: ['left'], + margin: ['marginTop', 'marginBottom', 'marginLeft', 'marginRight'], + marginBottom: ['marginBottom'], + marginEnd: ['marginEnd'], + marginHorizontal: ['marginLeft', 'marginRight'], + marginLeft: ['marginLeft'], + marginRight: ['marginRight'], + marginStart: ['marginStart'], + marginTop: ['marginTop'], + marginVertical: ['marginTop', 'marginBottom'], + maxHeight: ['maxHeight'], + maxWidth: ['maxWidth'], + overflow: ['overflow'], + padding: ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], + paddingBottom: ['paddingBottom'], + paddingEnd: ['paddingEnd'], + paddingHorizontal: ['paddingLeft', 'paddingRight'], + paddingLeft: ['paddingLeft'], + paddingRight: ['paddingRight'], + paddingStart: ['paddingStart'], + paddingTop: ['paddingTop'], + paddingVertical: ['paddingTop', 'paddingBottom'], + position: ['position'], + right: ['right'], + start: ['start'], + top: ['top'], + width: ['width'], + zIndex: ['zIndex'], + direction: ['direction'], + + // ShadowStyle + shadowColor: ['shadowColor'], + shadowOffset: ['shadowOffset'], + shadowOpacity: ['shadowOpacity'], + shadowRadius: ['shadowRadius'], + + // TransformStyle + transform: ['transform'], + transformMatrix: ['transformMatrix'], + rotation: ['rotation'], + scaleX: ['scaleX'], + scaleY: ['scaleY'], + translateX: ['translateX'], + translateY: ['translateY'], + + // ViewStyle + backfaceVisibility: ['backfaceVisibility'], + backgroundColor: ['backgroundColor'], + borderBlockColor: ['borderBlockColor'], + borderBlockEndColor: ['borderBlockEndColor'], + borderBlockStartColor: ['borderBlockStartColor'], + borerBottomColor: ['borderBottomColor'], + borderBottomEndRadius: ['borderBottomEndRadius'], + borderBottomLeftRadius: ['borderBottomLeftRadius'], + borderBottomRightRadius: ['borderBottomRightRadius'], + borderBottomStartRadius: ['borderBottomStartRadius'], + borderColor: ['borderColor'], + borderCurve: ['borderCurve'], + borderEndColor: ['borderEndColor'], + borderEndEndRadius: ['borderEndEndRadius'], + borderEndStartRadius: ['borderEndStartRadius'], + borderLeftColor: ['borderLeftColor'], + borderRadius: ['borderRadius'], + borderRightColor: ['borderRightColor'], + borderStartColor: ['borderStartColor'], + borderStartEndRadius: ['borderStartEndRadius'], + borderStartStartRadius: ['borderStartStartRadius'], + borderStyle: ['borderStyle'], + borderTopColor: ['borderTopColor'], + borderTopEndRadius: ['borderTopEndRadius'], + borderTopLeftRadius: ['borderTopLeftRadius'], + borderTopRightRadius: ['borderTopRightRadius'], + borderTopStartRadius: ['borderTopStartRadius'], + opacity: ['opacity'], + elevation: ['elevation'], + pointerEvents: ['pointerEvents'], + + // TextStyleIOS + fontVariant: ['fontVariant'], + textDecorationColor: ['textDecorationColor'], + textDecorationStyle: ['textDecorationStyle'], + writingDirection: ['writingDirection'], + + // TextStyleAndroid + textAlignVertical: ['textAlignVertical'], + verticalAlign: ['verticalAlign'], + includeFontPadding: ['includeFontPadding'], + + // TextStyle + color: ['color'], + fontFamily: ['fontFamily'], + fontSize: ['fontSize'], + fontStyle: ['fontStyle'], + fontWeight: ['fontWeight'], + letterSpacing: ['letterSpacing'], + lineHeight: ['lineHeight'], + textAlign: ['textAlign'], + textDecorationLine: ['textDecorationLine'], + textShadowColor: ['textShadowColor'], + textShadowOffset: ['textShadowOffset'], + textTransform: ['textTransform'], + + // ImageStyle + resizeMode: ['resizeMode'], + overlayColor: ['overlayColor'], + tintColor: ['tintColor'], + objectFit: ['objectFit'], +} + +export const createTheme = < + T extends Tokens, + P extends Properties, + M extends Macros, + B extends Breakpoints, +>({ + tokens, + properties: userProperties = {}, + macros: userMacros = {}, + breakpoints: userBreakpoints = {}, +}: { + tokens: T + properties?: Partial

+ macros?: Partial + breakpoints?: Partial +}): Theme => { + const properties = Object.assign({}, propertyMapping, userProperties) as P + const macros = userMacros as M + const breakpoints = Object.entries(userBreakpoints) + .sort(([_a, a], [_b, b]) => { + return a - b + }) + .reduce((breakpoints, [key, value]) => { + breakpoints[key as keyof B] = value + return breakpoints + }, {} as B) + + type InnerTheme = Theme< + typeof tokens, + typeof properties, + typeof macros, + typeof breakpoints + > + + function _applyStyleProperty(styles: StyleObject, prop: string, value: any) { + for (const _prop of properties[prop]) { + // @ts-ignore no index sig, it's fine + const t = specialTokenMapping[_prop] + ? // @ts-ignore no index sig, it's fine + tokens[specialTokenMapping[_prop]] + : tokens[_prop] + styles[_prop] = t?.[value] || value + } + } + + const style: InnerTheme['style'] = (rawProps, activeBreakpoints = []) => { + let styles: StyleObject = {} + const props: any = {} + + Object.keys(rawProps).forEach(prop => { + // @ts-ignore no index sig, it's fine + const value = rawProps[prop] + + if (value === undefined) return + + if (properties[prop]) { + _applyStyleProperty(styles, prop, value) + } else if (macros[prop]) { + styles = { + ...styles, + // @ts-ignore no index sig, it's fine + ...(rawProps[prop] === true ? macros[prop](value, tokens) : {}), + } + } else if (breakpoints[prop]) { + for (const b of Object.keys(breakpoints)) { + if (activeBreakpoints.includes(b)) { + // @ts-ignore no index sig, it's fine + const breakpointStyles = rawProps[b] || {} + const r = style(breakpointStyles, activeBreakpoints) + styles = {...styles, ...r.styles} + } else { + // @ts-ignore no index sig, it's fine + delete rawProps[b] + } + } + } else { + // @ts-ignore no index sig, it's fine + props[prop] = value + } + }) + + return { + styles: StyleSheet.create({sheet: styles}).sheet, + props: props, + } + } + + function getActiveBreakpoints({width}: {width: number}) { + const active: (keyof typeof breakpoints)[] = Object.keys( + breakpoints, + ).filter(breakpoint => width >= breakpoints[breakpoint]) + + return { + active, + current: active[active.length - 1], + } + } + + return { + config: { + tokens, + properties, + macros, + breakpoints, + }, + style, + getActiveBreakpoints, + } +} diff --git a/src/view/nova/lib/utils.ts b/src/view/nova/lib/utils.ts new file mode 100644 index 0000000000..544f5480b6 --- /dev/null +++ b/src/view/nova/lib/utils.ts @@ -0,0 +1,25 @@ +import {Platform} from 'react-native' + +export function web(value: any) { + return Platform.select({ + web: value, + }) +} + +export function ios(value: any) { + return Platform.select({ + ios: value, + }) +} + +export function android(value: any) { + return Platform.select({ + android: value, + }) +} + +export function native(value: any) { + return Platform.select({ + native: value, + }) +} diff --git a/src/view/nova/themes.ts b/src/view/nova/themes.ts new file mode 100644 index 0000000000..a4d586dc8c --- /dev/null +++ b/src/view/nova/themes.ts @@ -0,0 +1,113 @@ +import {createTheme} from './lib/theme' + +const palette = { + white: '#FFFFFF', + black: '#0A0B0D', + gray1: '#F7F9FC', + gray2: '#E4E8EE', + gray3: '#CED5DE', + gray4: '#828A99', + gray5: '#4D5564', + gray6: '#1D2126', + blue: '#1185FE', + green: '#54D469', + red: '#FB4566', +} + +export const light = createTheme({ + tokens: { + color: { + primary: palette.blue, + l1: palette.white, + l2: palette.gray1, + l3: palette.gray2, + l4: palette.gray3, + l5: palette.gray4, + l6: palette.gray5, + l7: palette.gray6, + l8: palette.black, + }, + fontSize: { + xxs: 10, + xs: 12, + s: 14, + m: 16, + l: 18, + xl: 22, + xxl: 26, + }, + lineHeight: { + xs: 12, + s: 14, + m: 16, + l: 18, + xl: 22, + }, + }, + properties: { + w: ['width'], + h: ['height'], + c: ['color'], + bg: ['backgroundColor'], + ma: ['marginTop', 'marginBottom', 'marginLeft', 'marginRight'], + mt: ['marginTop'], + mb: ['marginBottom'], + ml: ['marginLeft'], + mr: ['marginRight'], + my: ['marginTop', 'marginBottom'], + mx: ['marginLeft', 'marginRight'], + /** + * Alias for `padding`, maps to all padding properties e.g. `paddingTop`, + * `paddingBottom`, etc. + */ + pa: ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'], + pt: ['paddingTop'], + pb: ['paddingBottom'], + pl: ['paddingLeft'], + pr: ['paddingRight'], + py: ['paddingTop', 'paddingBottom'], + px: ['paddingLeft', 'paddingRight'], + z: ['zIndex'], + radius: ['borderRadius'], + }, + macros: { + row: (_: boolean) => ({flexDirection: 'row'}), + column: (_: boolean) => ({flex: 1}), + abs: (_: boolean) => ({position: 'absolute'}), + cover: (_: boolean) => ({ + top: 0, + bottom: 0, + left: 0, + right: 0, + }), + caps: (_: boolean) => ({textTransform: 'uppercase'}), + fontSize(value: 'xs' | 's' | 'm' | 'l' | 'xl', tokens) { + return { + fontSize: tokens.fontSize[value], + lineHeight: tokens.lineHeight[value], + } + }, + }, + breakpoints: { + gtMobile: 800, + gtTablet: 1300, + }, +}) + +export const dark = createTheme({ + ...light.config, + tokens: { + ...light.config.tokens, + color: { + ...light.config.tokens.color, + l1: palette.black, + l2: palette.gray6, + l3: palette.gray5, + l4: palette.gray4, + l5: palette.gray3, + l6: palette.gray2, + l7: palette.gray1, + l8: palette.white, + }, + }, +})