From 3ec6701b8e14d0336cf28a0c347a4b1e05f3a7f4 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 12 Sep 2023 11:41:33 -0500 Subject: [PATCH 01/10] bop it (cherry picked from commit a5ea4d866241ce9794f1087a8218b71d95cdf7b7) --- package.json | 2 + src/Navigation.tsx | 6 + src/lib/design-system/__tests__/lib.test.ts | 109 ++++++ src/lib/design-system/index.tsx | 190 ++++++++++ src/lib/design-system/lib.ts | 383 ++++++++++++++++++++ src/lib/design-system/themes.ts | 108 ++++++ src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/view/screens/DesignSystem.tsx | 34 ++ src/view/screens/Settings.tsx | 14 + yarn.lock | 7 + 11 files changed, 855 insertions(+) create mode 100644 src/lib/design-system/__tests__/lib.test.ts create mode 100644 src/lib/design-system/index.tsx create mode 100644 src/lib/design-system/lib.ts create mode 100644 src/lib/design-system/themes.ts create mode 100644 src/view/screens/DesignSystem.tsx diff --git a/package.json b/package.json index 69ba6f6792..5fdc969241 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "lodash.chunk": "^4.2.0", "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", + "lodash.merge": "^4.6.2", "lodash.omit": "^4.5.0", "lodash.once": "^4.1.1", "lodash.samplesize": "^4.2.0", @@ -163,6 +164,7 @@ "@types/lodash.chunk": "^4.2.7", "@types/lodash.debounce": "^4.0.7", "@types/lodash.isequal": "^4.5.6", + "@types/lodash.merge": "^4.6.7", "@types/lodash.omit": "^4.5.7", "@types/lodash.once": "^4.1.7", "@types/lodash.samplesize": "^4.2.7", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index dac70dfc7e..1eaee80c35 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -68,6 +68,7 @@ import {bskyTitle} from 'lib/strings/headings' import {JSX} from 'react/jsx-runtime' import {timeout} from 'lib/async/timeout' import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed' +import {DesignSystemScreen} from 'view/screens/DesignSystem' const navigationRef = createNavigationContainerRef() @@ -225,6 +226,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { component={PreferencesHomeFeed} options={{title: title('Home Feed Preferences')}} /> + ) } diff --git a/src/lib/design-system/__tests__/lib.test.ts b/src/lib/design-system/__tests__/lib.test.ts new file mode 100644 index 0000000000..48da63189d --- /dev/null +++ b/src/lib/design-system/__tests__/lib.test.ts @@ -0,0 +1,109 @@ +import {describe, it, expect} from '@jest/globals' + +import {create} from '../lib' + +const theme = create({ + tokens: { + space: { + s: 10, + m: 16, + l: 24, + }, + color: { + primary: 'tomato', + }, + fontSize: { + s: 14, + m: 16, + l: 18, + }, + }, + properties: { + c: ['color'], + px: ['paddingLeft', 'paddingRight'], + fs: ['fontSize'], + }, + macros: { + font(value: 'inter' | 'mono') { + return { + fontFamily: value === 'inter' ? 'Inter' : 'Roboto Mono', + } + }, + }, + breakpoints: { + gtPhone: 640, + }, +}) + +describe('style: basic', () => { + it('works with configured properties', () => { + const styles = theme.style({ + color: 'primary', + paddingVertical: 's', + fontSize: 'm', + }) + + expect(styles).toEqual({ + color: theme.config.tokens.color.primary, + paddingTop: theme.config.tokens.space.s, + paddingBottom: theme.config.tokens.space.s, + fontSize: theme.config.tokens.fontSize.m, + }) + }) + + it('works with user-defined properties', () => { + const styles = theme.style({ + c: 'primary', + px: 's', + fontSize: 'm', + }) + + expect(styles).toEqual({ + color: theme.config.tokens.color.primary, + paddingLeft: theme.config.tokens.space.s, + paddingRight: theme.config.tokens.space.s, + fontSize: theme.config.tokens.fontSize.m, + }) + }) +}) + +describe('pick', () => { + it('works', () => { + const {styles, props} = theme.pick({ + c: 'primary', + gtPhone: { + px: 'm', + }, + accessibilityLabel: 'hello', + }) + + expect(styles).toEqual({ + default: { + c: 'primary', + }, + gtPhone: { + px: 'm', + }, + }) + expect(props).toEqual({ + accessibilityLabel: 'hello', + }) + }) +}) + +describe('getActiveBreakpoints', () => { + it('works', () => { + const activeBreakpoints = theme.getActiveBreakpoints({ + width: 1000, + }) + + expect(activeBreakpoints).toEqual({ + active: ['default', 'gtPhone'], + current: 'gtPhone', + }) + }) +}) + +describe('applyBreakpoints', () => { + it('works', () => {}) +}) diff --git a/src/lib/design-system/index.tsx b/src/lib/design-system/index.tsx new file mode 100644 index 0000000000..c8b7d5716d --- /dev/null +++ b/src/lib/design-system/index.tsx @@ -0,0 +1,190 @@ +import React from 'react' +import { + View, + Text as RNText, + Dimensions, + Platform, + ViewProps, + TextProps, + ImageProps, +} from 'react-native' +import merge from 'lodash.merge' + +import {Theme, light} from './themes' + +type ComponentProps = Partial +export type Props = Parameters>[0] & { + /** + * Debug mode will log the styles and props to the console + */ + debug?: boolean +} +type HeadingElements = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' +type TypeProps = Props & { + as?: HeadingElements +} + +const Context = React.createContext({theme: light}) + +export const ThemeProvider = ({ + children, + theme, +}: React.PropsWithChildren<{theme: Theme}>) => ( + {children} +) + +export function useBreakpoints() { + const {theme} = React.useContext(Context) + 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 +} + +export function useStyles(props: Props & T) { + const {theme} = React.useContext(Context) + const breakpoints = useBreakpoints() + const { + styles: responsiveStyles, + props: {debug, ...rest}, + } = React.useMemo( + () => theme.pick>(props), + [props, theme], + ) + const styles = React.useMemo(() => { + return theme.style( + theme.applyBreakpoints(responsiveStyles, breakpoints.active), + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [responsiveStyles, breakpoints.current, theme]) + + if (debug) { + console.debug({styles, props: rest, breakpoints}) + } + + return {styles, props: rest} +} + +export const Box = React.forwardRef>( + ({children, style, ...props}, ref) => { + const {styles, props: rest} = useStyles(props) + return ( + + {children} + + ) + }, +) + +export const Text = React.forwardRef>( + ({children, style, ...props}, ref) => { + const {styles, props: rest} = useStyles({ + color: 'text', + ...props, + }) + return ( + + {children} + + ) + }, +) + +const asToAriaLevel = { + h1: 1, + h2: 2, + h3: 3, + h4: 4, + h5: 5, + h6: 6, +} + +const asToTypeStyles: { + [key in HeadingElements]: Props +} = { + h1: { + fontSize: 'l', + lineHeight: 'l', + gtPhone: { + fontSize: 'xl', + lineHeight: 'xl', + }, + }, + h2: { + fontSize: 24, + lineHeight: 32, + }, + h3: { + fontSize: 20, + lineHeight: 28, + }, + h4: { + fontSize: 16, + lineHeight: 24, + }, + h5: { + fontSize: 14, + lineHeight: 20, + }, + h6: { + fontSize: 12, + lineHeight: 16, + }, +} + +export const P = React.forwardRef((props, ref) => { + // @ts-expect-error role is web only + return +}) + +/** + * @see https://necolas.github.io/react-native-web/docs/accessibility/#semantic-html + * @see https://docs.expo.dev/develop/user-interface/fonts/ + */ +function createHeadingComponent(element: HeadingElements) { + return React.forwardRef( + ({children, style, as, ...props}, ref) => { + const asEl = as || element + const extra = Platform.select({ + web: { + 'aria-level': asToAriaLevel[element], + }, + default: {}, + }) + const {styles, props: rest} = useStyles({ + color: 'text', + ...merge(asToTypeStyles[asEl], props), + }) + + return ( + + {children} + + ) + }, + ) +} + +export const H1 = createHeadingComponent('h1') +export const H2 = createHeadingComponent('h2') +export const H3 = createHeadingComponent('h3') +export const H4 = createHeadingComponent('h4') +export const H5 = createHeadingComponent('h5') +export const H6 = createHeadingComponent('h6') diff --git a/src/lib/design-system/lib.ts b/src/lib/design-system/lib.ts new file mode 100644 index 0000000000..be4498c436 --- /dev/null +++ b/src/lib/design-system/lib.ts @@ -0,0 +1,383 @@ +import type { + ViewStyle, + TextStyle, + ImageStyle, + DimensionValue, + ViewProps, + TextProps, + ImageProps, +} 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 +} + +type Styles> = { + [Property in ColorProperties]?: keyof T['color'] +} & { + [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 C]?: C[Property] extends ( + value: infer Value, + tokens: T, + ) => StyleObject + ? Value + : never +} + +type ResponsiveStyles< + B extends Breakpoints, + S extends Styles, +> = { + [Breakpoint in keyof B]?: S +} + +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 create = < + T extends Tokens, + P extends Properties, + C extends Macros, + B extends Breakpoints, +>({ + tokens, + properties: userProperties = {}, + macros: userMacros = {}, + breakpoints: userBreakpoints = {}, +}: { + tokens: T + properties?: Partial

+ macros?: Partial + breakpoints?: Partial +}) => { + const properties = Object.assign({}, propertyMapping, userProperties) as P + const macros = userMacros as C + const breakpoints = userBreakpoints as B + + const keyofProperties = Object.keys(properties) as (keyof P)[] + const keyofMacros = Object.keys(macros) as (keyof C)[] + const keyofBreakpoints = Object.keys(breakpoints) as (keyof B)[] + const allPropertyKeys = [...keyofProperties, ...keyofMacros] + + type InnerStyles = Styles + type InnerResponsiveStyles = ResponsiveStyles + + function pick>( + props: InnerStyles & InnerResponsiveStyles & Props, + ) { + const res = {styles: {default: {}}, props: {}} as { + styles: InnerResponsiveStyles & {default: InnerStyles} + props: Props + } + + for (const prop of Object.keys(props)) { + const value = props[prop] + if (value === undefined) continue + if (allPropertyKeys.includes(prop)) { + // @ts-ignore no index sig, it's fine + res.styles.default[prop] = value + } else if (keyofBreakpoints.includes(prop)) { + // @ts-ignore no index sig, it's fine + res.styles[prop] = value + } else { + // @ts-ignore no index sig, it's fine + res.props[prop] = value + } + } + + return res + } + + function style(styles: InnerStyles): StyleObject { + const s: StyleObject = {} + + for (const prop of Object.keys(styles)) { + const value = styles[prop] + + if (value === undefined) continue + + if (keyofProperties.includes(prop)) { + 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] + s[_prop] = t?.[value] || value + } + } else if (keyofMacros.includes(prop)) { + const property = macros[prop] + if (property) { + Object.assign(s, property(value, tokens)) + } + } else { + // @ts-ignore no index sig, it's fine + const t = specialTokenMapping[prop] + ? // @ts-ignore no index sig, it's fine + tokens[specialTokenMapping[prop]] + : // @ts-ignore no index sig, it's fine + tokens[prop] + s[prop as StyleObjectProperties] = t?.[value] || value + } + } + + return StyleSheet.create({sheet: s}).sheet + } + + function applyBreakpoints( + styles: InnerResponsiveStyles, + bp: (keyof typeof breakpoints | 'default')[], + ) { + let s = styles.default as InnerStyles + + for (const breakpoint of bp) { + s = {...s, ...styles[breakpoint]} + } + + return s + } + + function getActiveBreakpoints({width}: {width: number}) { + const active: (keyof typeof breakpoints | 'default')[] = ['default'] + + for (const breakpoint in breakpoints) { + if (width >= breakpoints[breakpoint]) { + active.push(breakpoint) + } + } + + return { + active, + current: active[active.length - 1], + } + } + + return { + config: { + tokens, + properties, + macros, + breakpoints, + }, + style, + pick, + applyBreakpoints, + getActiveBreakpoints, + } +} diff --git a/src/lib/design-system/themes.ts b/src/lib/design-system/themes.ts new file mode 100644 index 0000000000..eb73b8474e --- /dev/null +++ b/src/lib/design-system/themes.ts @@ -0,0 +1,108 @@ +import {create} from './lib' + +export type Theme = typeof light + +export const light = create({ + tokens: { + space: { + s: 10, + m: 16, + l: 24, + }, + color: { + theme: 'blue', + text: '#000', + }, + fontSize: { + xxs: 10, + xs: 12, + s: 14, + m: 16, + l: 24, + xl: 32, + xxl: 48, + }, + lineHeight: { + xxs: 10, + xs: 12, + s: 14, + m: 16, + l: 24, + xl: 32, + xxl: 48, + }, + fontFamily: { + inter: 'sans-serif', + roboto: 'monospace', + }, + }, + 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'], + 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'], + }, + macros: { + inline: (_: boolean) => ({flexDirection: 'row'}), + 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'}), + 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'}), + font(value: 'sans' | 'mono', tokens) { + return { + fontFamily: + value === 'sans' ? tokens.fontFamily.inter : tokens.fontFamily.roboto, + } + }, + }, + breakpoints: { + gtPhone: 640, + }, +}) + +export const dark: typeof light = create({ + ...light.config, + tokens: { + ...light.config.tokens, + color: { + theme: 'blue', + text: '#333', + }, + }, +}) diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 7159bcb51d..4e38d062b0 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -30,6 +30,7 @@ export type CommonNavigatorParams = { AppPasswords: undefined SavedFeeds: undefined PreferencesHomeFeed: undefined + DesignSystem: undefined } export type BottomTabNavigatorParams = CommonNavigatorParams & { diff --git a/src/routes.ts b/src/routes.ts index 45a8fa5724..0d6a27cce4 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -21,6 +21,7 @@ export const router = new Router({ CustomFeed: '/profile/:name/feed/:rkey', CustomFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', Debug: '/sys/debug', + DesignSystem: '/sys/ds', Log: '/sys/log', AppPasswords: '/settings/app-passwords', PreferencesHomeFeed: '/settings/home-feed', diff --git a/src/view/screens/DesignSystem.tsx b/src/view/screens/DesignSystem.tsx new file mode 100644 index 0000000000..ab647d2fb6 --- /dev/null +++ b/src/view/screens/DesignSystem.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' + +import {ThemeProvider, Box, Text, H1, H2, H3, P} from 'lib/design-system' +import * as themes from 'lib/design-system/themes' + +type Props = NativeStackScreenProps + +export const DesignSystemScreen = withAuthRequired( + observer(function DesignSystem({}: Props) { + return ( + + +

+ Heading 1 +

+

+ Heading 2 +

+

Heading 3

+

Paragraph

+ + + + + Monospace + + + + ) + }), +) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 8a543fa4c7..dcbd0e7c0a 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -192,6 +192,10 @@ export const SettingsScreen = withAuthRequired( navigation.navigate('Debug') }, [navigation]) + const onPressDesignSystem = React.useCallback(() => { + navigation.navigate('DesignSystem') + }, [navigation]) + const onPressSavedFeeds = React.useCallback(() => { navigation.navigate('SavedFeeds') }, [navigation]) @@ -564,6 +568,16 @@ export const SettingsScreen = withAuthRequired( Storybook
+ + + Design System + + Date: Wed, 13 Sep 2023 09:59:59 +0530 Subject: [PATCH 02/10] fix unnamed component lint error --- src/lib/design-system/index.tsx | 81 +++++++++++++++++---------------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/src/lib/design-system/index.tsx b/src/lib/design-system/index.tsx index c8b7d5716d..c35dd317df 100644 --- a/src/lib/design-system/index.tsx +++ b/src/lib/design-system/index.tsx @@ -77,19 +77,20 @@ export function useStyles(props: Props & T) { return {styles, props: rest} } -export const Box = React.forwardRef>( - ({children, style, ...props}, ref) => { - const {styles, props: rest} = useStyles(props) - return ( - - {children} - - ) - }, -) +export const Box = React.forwardRef>(function BoxThemed( + {children, style, ...props}, + ref, +) { + const {styles, props: rest} = useStyles(props) + return ( + + {children} + + ) +}) export const Text = React.forwardRef>( - ({children, style, ...props}, ref) => { + function TextThemed({children, style, ...props}, ref) { const {styles, props: rest} = useStyles({ color: 'text', ...props, @@ -144,7 +145,10 @@ const asToTypeStyles: { }, } -export const P = React.forwardRef((props, ref) => { +export const P = React.forwardRef(function PThemed( + props, + ref, +) { // @ts-expect-error role is web only return }) @@ -154,32 +158,33 @@ export const P = React.forwardRef((props, ref) => { * @see https://docs.expo.dev/develop/user-interface/fonts/ */ function createHeadingComponent(element: HeadingElements) { - return React.forwardRef( - ({children, style, as, ...props}, ref) => { - const asEl = as || element - const extra = Platform.select({ - web: { - 'aria-level': asToAriaLevel[element], - }, - default: {}, - }) - const {styles, props: rest} = useStyles({ - color: 'text', - ...merge(asToTypeStyles[asEl], props), - }) - - return ( - - {children} - - ) - }, - ) + return React.forwardRef(function HeadingThemed( + {children, style, as, ...props}, + ref, + ) { + const asEl = as || element + const extra = Platform.select({ + web: { + 'aria-level': asToAriaLevel[element], + }, + default: {}, + }) + const {styles, props: rest} = useStyles({ + color: 'text', + ...merge(asToTypeStyles[asEl], props), + }) + + return ( + + {children} + + ) + }) } export const H1 = createHeadingComponent('h1') From 10702e9733277a14c3113f2cd56b1a555095238a Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 13 Sep 2023 16:56:40 -0500 Subject: [PATCH 03/10] add useMultiStyleExample --- src/lib/design-system/index.tsx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/lib/design-system/index.tsx b/src/lib/design-system/index.tsx index c35dd317df..dd221d5e29 100644 --- a/src/lib/design-system/index.tsx +++ b/src/lib/design-system/index.tsx @@ -77,6 +77,27 @@ export function useStyles(props: Props & T) { return {styles, props: rest} } +export function useMultiStyle< + O extends Record>[0]>, +>( + styles: O, +): { + [Name in keyof O]: ReturnType +} { + const {theme} = React.useContext(Context) + const breakpoints = useBreakpoints() + + return React.useMemo(() => { + return Object.entries(styles).reduce((acc, [key, style]) => { + acc[key as keyof O] = theme.style( + theme.applyBreakpoints(style, breakpoints.active), + ) + return acc + }, {} as {[Name in keyof O]: ReturnType}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [styles, breakpoints.current, theme]) +} + export const Box = React.forwardRef>(function BoxThemed( {children, style, ...props}, ref, From 9a2fc9d9bd240b81ec98b8415129f1d16cb59fcb Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 14 Sep 2023 10:26:20 -0500 Subject: [PATCH 04/10] add comments --- src/lib/design-system/themes.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/lib/design-system/themes.ts b/src/lib/design-system/themes.ts index eb73b8474e..3ae3bbc04b 100644 --- a/src/lib/design-system/themes.ts +++ b/src/lib/design-system/themes.ts @@ -49,6 +49,10 @@ export const light = create({ 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'], @@ -83,6 +87,9 @@ export const light = create({ tar: (_: boolean) => ({textAlign: 'right'}), mxa: (_: boolean) => ({marginLeft: 'auto', marginRight: 'auto'}), mya: (_: boolean) => ({marginTop: 'auto', marginBottom: 'auto'}), + /** + * Macro for applying `{ textTransform: 'uppercase' }` to a style. + */ caps: (_: boolean) => ({textTransform: 'uppercase'}), font(value: 'sans' | 'mono', tokens) { return { From 737e1d6453eadc24d14c77afcb172bb478f40507 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 14 Sep 2023 19:34:50 -0500 Subject: [PATCH 05/10] cleanup and better naming --- src/lib/design-system/index.tsx | 80 +++++++++++++++---------------- src/lib/design-system/lib.ts | 19 ++++---- src/view/screens/DesignSystem.tsx | 4 +- 3 files changed, 52 insertions(+), 51 deletions(-) diff --git a/src/lib/design-system/index.tsx b/src/lib/design-system/index.tsx index dd221d5e29..5eb83f3ec0 100644 --- a/src/lib/design-system/index.tsx +++ b/src/lib/design-system/index.tsx @@ -12,15 +12,19 @@ import merge from 'lodash.merge' import {Theme, light} from './themes' -type ComponentProps = Partial -export type Props = Parameters>[0] & { +type NativeProps = Partial +export type StyleProps = Parameters[0] & + Record +export type ComponentProps = Parameters< + typeof light.pick +>[0] & { /** * Debug mode will log the styles and props to the console */ debug?: boolean } type HeadingElements = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' -type TypeProps = Props & { +type TypeProps = ComponentProps & { as?: HeadingElements } @@ -53,44 +57,35 @@ export function useBreakpoints() { return breakpoints } -export function useStyles(props: Props & T) { +export function usePick(props: ComponentProps) { + const {theme} = React.useContext(Context) + return React.useMemo(() => theme.pick(props), [props, theme]) +} + +export function useStyle(props: StyleProps) { const {theme} = React.useContext(Context) const breakpoints = useBreakpoints() - const { - styles: responsiveStyles, - props: {debug, ...rest}, - } = React.useMemo( - () => theme.pick>(props), - [props, theme], - ) - const styles = React.useMemo(() => { + const {styles: responsiveStyles} = usePick(props) + return React.useMemo(() => { return theme.style( theme.applyBreakpoints(responsiveStyles, breakpoints.active), ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [responsiveStyles, breakpoints.current, theme]) - - if (debug) { - console.debug({styles, props: rest, breakpoints}) - } - - return {styles, props: rest} } -export function useMultiStyle< - O extends Record>[0]>, ->( +export function useStyles>( styles: O, ): { [Name in keyof O]: ReturnType } { const {theme} = React.useContext(Context) const breakpoints = useBreakpoints() - return React.useMemo(() => { return Object.entries(styles).reduce((acc, [key, style]) => { + const responsiveStyles = theme.pick(style).styles acc[key as keyof O] = theme.style( - theme.applyBreakpoints(style, breakpoints.active), + theme.applyBreakpoints(responsiveStyles, breakpoints.active), ) return acc }, {} as {[Name in keyof O]: ReturnType}) @@ -98,24 +93,27 @@ export function useMultiStyle< }, [styles, breakpoints.current, theme]) } -export const Box = React.forwardRef>(function BoxThemed( - {children, style, ...props}, - ref, -) { - const {styles, props: rest} = useStyles(props) - return ( - - {children} - - ) -}) +export const Box = React.forwardRef>( + function BoxThemed({children, style, ...props}, ref) { + const {styles: pickedStyles, props: rest} = usePick(props) + const styles = useStyle(pickedStyles) + if (props.debug) console.log({styles: pickedStyles, props: rest}) + return ( + + {children} + + ) + }, +) -export const Text = React.forwardRef>( +export const Text = React.forwardRef>( function TextThemed({children, style, ...props}, ref) { - const {styles, props: rest} = useStyles({ + const {styles: pickedStyles, props: rest} = usePick(props) + const styles = useStyle({ color: 'text', - ...props, + ...pickedStyles, }) + if (props.debug) console.log({styles, props: rest}) return ( {children} @@ -134,7 +132,7 @@ const asToAriaLevel = { } const asToTypeStyles: { - [key in HeadingElements]: Props + [key in HeadingElements]: ComponentProps } = { h1: { fontSize: 'l', @@ -190,10 +188,12 @@ function createHeadingComponent(element: HeadingElements) { }, default: {}, }) - const {styles, props: rest} = useStyles({ + const {styles: pickedStyles, props: rest} = usePick(props) + const styles = useStyle({ color: 'text', - ...merge(asToTypeStyles[asEl], props), + ...merge(asToTypeStyles[asEl], pickedStyles), }) + if (props.debug) console.debug({styles, props: rest}) return ( > = { type ResponsiveStyles< B extends Breakpoints, S extends Styles, -> = { +> = S & { [Breakpoint in keyof B]?: S } @@ -279,10 +279,10 @@ export const create = < type InnerResponsiveStyles = ResponsiveStyles function pick>( - props: InnerStyles & InnerResponsiveStyles & Props, + props: InnerResponsiveStyles & Props, ) { - const res = {styles: {default: {}}, props: {}} as { - styles: InnerResponsiveStyles & {default: InnerStyles} + const res = {styles: {}, props: {}} as { + styles: InnerResponsiveStyles props: Props } @@ -291,7 +291,7 @@ export const create = < if (value === undefined) continue if (allPropertyKeys.includes(prop)) { // @ts-ignore no index sig, it's fine - res.styles.default[prop] = value + res.styles[prop] = value } else if (keyofBreakpoints.includes(prop)) { // @ts-ignore no index sig, it's fine res.styles[prop] = value @@ -342,19 +342,20 @@ export const create = < function applyBreakpoints( styles: InnerResponsiveStyles, - bp: (keyof typeof breakpoints | 'default')[], + bp: (keyof typeof breakpoints)[], ) { - let s = styles.default as InnerStyles + let s = styles for (const breakpoint of bp) { - s = {...s, ...styles[breakpoint]} + const o = styles[breakpoint] || {} + s = {...s, ...o} } return s } function getActiveBreakpoints({width}: {width: number}) { - const active: (keyof typeof breakpoints | 'default')[] = ['default'] + const active: (keyof typeof breakpoints)[] = [] for (const breakpoint in breakpoints) { if (width >= breakpoints[breakpoint]) { diff --git a/src/view/screens/DesignSystem.tsx b/src/view/screens/DesignSystem.tsx index ab647d2fb6..7034b4785f 100644 --- a/src/view/screens/DesignSystem.tsx +++ b/src/view/screens/DesignSystem.tsx @@ -12,8 +12,8 @@ export const DesignSystemScreen = withAuthRequired( observer(function DesignSystem({}: Props) { return ( - -

+ +

Heading 1

From c0c4722b2f6a53db9174c8ac348758adf6ada90e Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 14 Sep 2023 19:43:53 -0500 Subject: [PATCH 06/10] multi-theme --- src/lib/design-system/index.tsx | 33 +++++++++++++++++++++++++++---- src/view/screens/DesignSystem.tsx | 3 +-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/lib/design-system/index.tsx b/src/lib/design-system/index.tsx index 5eb83f3ec0..61fc2bbd1a 100644 --- a/src/lib/design-system/index.tsx +++ b/src/lib/design-system/index.tsx @@ -10,7 +10,7 @@ import { } from 'react-native' import merge from 'lodash.merge' -import {Theme, light} from './themes' +import {Theme, light, dark} from './themes' type NativeProps = Partial export type StyleProps = Parameters[0] & @@ -28,13 +28,38 @@ type TypeProps = ComponentProps & { as?: HeadingElements } -const Context = React.createContext({theme: light}) +const themes = { + light, + dark, +} +type ThemeName = keyof typeof themes +const Context = React.createContext<{ + themeName: ThemeName + theme: Theme + themes: { + [key in ThemeName]: Theme + } +}>({ + themeName: 'light', + theme: light, + themes: { + light, + dark, + }, +}) export const ThemeProvider = ({ children, theme, -}: React.PropsWithChildren<{theme: Theme}>) => ( - {children} +}: React.PropsWithChildren<{theme: ThemeName}>) => ( + + {children} + ) export function useBreakpoints() { diff --git a/src/view/screens/DesignSystem.tsx b/src/view/screens/DesignSystem.tsx index 7034b4785f..cc48b6cc6a 100644 --- a/src/view/screens/DesignSystem.tsx +++ b/src/view/screens/DesignSystem.tsx @@ -4,14 +4,13 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ThemeProvider, Box, Text, H1, H2, H3, P} from 'lib/design-system' -import * as themes from 'lib/design-system/themes' type Props = NativeStackScreenProps export const DesignSystemScreen = withAuthRequired( observer(function DesignSystem({}: Props) { return ( - +

Heading 1 From bf8c1bb947130116ba1a3713113472bf2148a114 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 14 Sep 2023 19:45:26 -0500 Subject: [PATCH 07/10] useTheme --- src/lib/design-system/index.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/lib/design-system/index.tsx b/src/lib/design-system/index.tsx index 61fc2bbd1a..cae17badba 100644 --- a/src/lib/design-system/index.tsx +++ b/src/lib/design-system/index.tsx @@ -62,8 +62,12 @@ export const ThemeProvider = ({ ) +export function useTheme() { + return React.useContext(Context) +} + export function useBreakpoints() { - const {theme} = React.useContext(Context) + const {theme} = useTheme() const [breakpoints, setBreakpoints] = React.useState( theme.getActiveBreakpoints({width: Dimensions.get('window').width}), ) @@ -83,12 +87,12 @@ export function useBreakpoints() { } export function usePick(props: ComponentProps) { - const {theme} = React.useContext(Context) + const {theme} = useTheme() return React.useMemo(() => theme.pick(props), [props, theme]) } export function useStyle(props: StyleProps) { - const {theme} = React.useContext(Context) + const {theme} = useTheme() const breakpoints = useBreakpoints() const {styles: responsiveStyles} = usePick(props) return React.useMemo(() => { @@ -104,7 +108,7 @@ export function useStyles>( ): { [Name in keyof O]: ReturnType } { - const {theme} = React.useContext(Context) + const {theme} = useTheme() const breakpoints = useBreakpoints() return React.useMemo(() => { return Object.entries(styles).reduce((acc, [key, style]) => { From 2a6bea462c0bb4b21f48be10f8ac27e8b6c00ed5 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 14 Sep 2023 20:18:54 -0500 Subject: [PATCH 08/10] add some theme values --- src/lib/design-system/themes.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/lib/design-system/themes.ts b/src/lib/design-system/themes.ts index 3ae3bbc04b..045ac4e3b2 100644 --- a/src/lib/design-system/themes.ts +++ b/src/lib/design-system/themes.ts @@ -10,8 +10,17 @@ export const light = create({ l: 24, }, color: { - theme: 'blue', + surface: '#fff', + + // Text text: '#000', + textInverted: '#fff', + + // Interactive elements + textLink: '#0085ff', + + // UI + border: '#f0e9e9', }, fontSize: { xxs: 10, @@ -66,6 +75,7 @@ export const light = create({ fw: ['fontWeight'], lh: ['lineHeight'], ta: ['textAlign'], + radius: ['borderRadius'], }, macros: { inline: (_: boolean) => ({flexDirection: 'row'}), @@ -108,8 +118,17 @@ export const dark: typeof light = create({ tokens: { ...light.config.tokens, color: { - theme: 'blue', - text: '#333', + surface: '#000', + + // Text + text: '#fff', + textInverted: '#000', + + // Interactive elements + textLink: '#0085ff', + + // UI + border: '#f0e9e9', }, }, }) From ca50055118b823efbfff6bac5e028289065e4067 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 14 Sep 2023 20:19:02 -0500 Subject: [PATCH 09/10] refactor toggle button --- src/view/com/util/forms/ToggleButton.tsx | 53 ++++++++++++++++++++++-- src/view/screens/DesignSystem.tsx | 22 ++++++++-- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/view/com/util/forms/ToggleButton.tsx b/src/view/com/util/forms/ToggleButton.tsx index 46ceb8c818..aab917dd03 100644 --- a/src/view/com/util/forms/ToggleButton.tsx +++ b/src/view/com/util/forms/ToggleButton.tsx @@ -1,12 +1,55 @@ import React from 'react' -import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' -import {Text} from '../text/Text' +import { + StyleProp, + StyleSheet, + TextStyle, + View, + ViewStyle, + Pressable, +} from 'react-native' +import {Text as RNText} from '../text/Text' import {Button, ButtonType} from './Button' import {useTheme} from 'lib/ThemeContext' import {choose} from 'lib/functions' import {colors} from 'lib/styles' import {TypographyVariant} from 'lib/ThemeContext' +import {ComponentProps, Box, Text} from 'lib/design-system' + +export function Toggle({ + children, + selected, + ...props +}: React.PropsWithChildren< + ComponentProps & { + selected: boolean + } +>) { + return ( + + + + + + + + {/* @ts-ignore userSelect is a web property */} + {children} + + + + ) +} + export function ToggleButton({ type = 'default-light', label, @@ -146,9 +189,11 @@ export function ToggleButton({ /> {label === '' ? null : ( - + {label} - + )} diff --git a/src/view/screens/DesignSystem.tsx b/src/view/screens/DesignSystem.tsx index cc48b6cc6a..960734e40e 100644 --- a/src/view/screens/DesignSystem.tsx +++ b/src/view/screens/DesignSystem.tsx @@ -4,28 +4,44 @@ import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ThemeProvider, Box, Text, H1, H2, H3, P} from 'lib/design-system' +import {Toggle} from 'view/com/util/forms/ToggleButton' type Props = NativeStackScreenProps +function ToggleButtons() { + const [selected, setSelected] = React.useState(false) + return ( + <> + setSelected(!selected)}> + Toggle button + + + ) +} + export const DesignSystemScreen = withAuthRequired( observer(function DesignSystem({}: Props) { return ( -

+

Heading 1

-

+

Heading 2

Heading 3

Paragraph

- + Monospace + + + +
) From 7f5456d0a8a54691f3a027a63babb14b22743d25 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Thu, 14 Sep 2023 20:30:10 -0500 Subject: [PATCH 10/10] comment --- src/lib/design-system/themes.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/design-system/themes.ts b/src/lib/design-system/themes.ts index 045ac4e3b2..56690b0045 100644 --- a/src/lib/design-system/themes.ts +++ b/src/lib/design-system/themes.ts @@ -9,6 +9,7 @@ export const light = create({ m: 16, l: 24, }, + // naming inspo https://polaris.shopify.com/tokens/color color: { surface: '#fff',