From 221add5aae749f338cb171b0409522fe9808febf Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 19 Oct 2023 11:56:57 -0400 Subject: [PATCH] setup --- src/view/nova/index.tsx | 54 +++++ src/view/nova/lib/system.tsx | 149 ++++++++++++++ src/view/nova/lib/theme.ts | 384 +++++++++++++++++++++++++++++++++++ src/view/nova/lib/utils.ts | 25 +++ src/view/nova/themes.ts | 129 ++++++++++++ src/view/shell/index.tsx | 19 +- 6 files changed, 752 insertions(+), 8 deletions(-) create mode 100644 src/view/nova/index.tsx create mode 100644 src/view/nova/lib/system.tsx create mode 100644 src/view/nova/lib/theme.ts create mode 100644 src/view/nova/lib/utils.ts create mode 100644 src/view/nova/themes.ts diff --git a/src/view/nova/index.tsx b/src/view/nova/index.tsx new file mode 100644 index 0000000000..c9257d227e --- /dev/null +++ b/src/view/nova/index.tsx @@ -0,0 +1,54 @@ +import { View, Text as RNText, Pressable as RNPressable } 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, + useStyle, + useStyles, + styled, +} = createSystem({ + light, + dark, +}) + +export { + ThemeProvider, + useTheme, + useStyle, + useStyles, + styled, +} + +export const Box = styled(View) +export const Pressable = styled(RNPressable) +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, { + role: web('heading'), + color: 'l8', + fontSize: 'l', + gtMobile: { + fontSize: 'xl', + }, + ...web({ + 'aria-level': 1, + }), +}) +export const P = styled(RNText, { + role: web('paragraph'), + color: 'l8', + fontSize: 's', +}) diff --git a/src/view/nova/lib/system.tsx b/src/view/nova/lib/system.tsx new file mode 100644 index 0000000000..3a069ff075 --- /dev/null +++ b/src/view/nova/lib/system.tsx @@ -0,0 +1,149 @@ +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 theme = Object.values(themes)[0]; + + type SystemTheme = Theme; + type ResponsiveStyleProps = ResponsiveStyles; + 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, + themes, + }); + + const ThemeProvider = ({ + children, + theme, + }: React.PropsWithChildren<{ theme: ThemeName }>) => ( + + {children} + + ); + + function useTheme() { + return React.useContext(Context); + } + + 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, breakpoints.active).styles; + } + + function useStyles>( + styles: Record, + ): { + [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, 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 = {}, + ) { + return React.forwardRef< + T, + T & + 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 ; + }); + } + + return { + Context, + ThemeProvider, + useTheme, + 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..c9fb78ab88 --- /dev/null +++ b/src/view/nova/lib/theme.ts @@ -0,0 +1,384 @@ +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> = { + [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..877c485618 --- /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..0b2f65958c --- /dev/null +++ b/src/view/nova/themes.ts @@ -0,0 +1,129 @@ +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: { + xs: 12, + s: 14, + m: 16, + l: 18, + xl: 22, + }, + lineHeight: { + xs: 12, + s: 14, + m: 16, + l: 18, + xl: 22, + }, + }, + properties: { + d: ['display'], + 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'], + fs: ['fontSize'], + ff: ['fontFamily'], + fw: ['fontWeight'], + lh: ['lineHeight'], + ta: ['textAlign'], + radius: ['borderRadius'], + }, + macros: { + row: (_: boolean) => ({ flexDirection: 'row' }), + column: (_: boolean) => ({ flex: 1 }), + aic: (_: boolean) => ({ alignItems: 'center' }), + aie: (_: boolean) => ({ alignItems: 'flex-end' }), + jcs: (_: boolean) => ({ justifyContent: 'flex-start' }), + jcc: (_: boolean) => ({ justifyContent: 'center' }), + jce: (_: boolean) => ({ justifyContent: 'flex-end' }), + jcb: (_: boolean) => ({ justifyContent: 'space-between' }), + fdr: (_: boolean) => ({ flexDirection: 'row' }), + rel: (_: boolean) => ({ position: 'relative' }), + abs: (_: boolean) => ({ position: 'absolute' }), + cover: (_: boolean) => ({ + top: 0, + bottom: 0, + left: 0, + right: 0, + }), + tac: (_: boolean) => ({ textAlign: 'center' }), + tar: (_: boolean) => ({ textAlign: 'right' }), + mxa: (_: boolean) => ({ marginLeft: 'auto', marginRight: 'auto' }), + mya: (_: boolean) => ({ marginTop: 'auto', marginBottom: 'auto' }), + 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, + } + }, +}) diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 3119715e94..8f8581cf1f 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -23,6 +23,7 @@ import {RoutesContainer, TabsNavigator} from '../../Navigation' import {isStateAtTabRoot} from 'lib/routes/helpers' import {SafeAreaProvider} from 'react-native-safe-area-context' import {useOTAUpdate} from 'lib/hooks/useOTAUpdate' +import { ThemeProvider } from 'view/nova' const ShellInner = observer(function ShellInnerImpl() { const store = useStores() @@ -87,14 +88,16 @@ export const Shell: React.FC = observer(function ShellImpl() { const pal = usePalette('default') const theme = useTheme() return ( - - - - - - - - + + + + + + + + + + ) })