diff options
Diffstat (limited to 'src/alf')
-rw-r--r-- | src/alf/README.md | 56 | ||||
-rw-r--r-- | src/alf/atoms.ts | 514 | ||||
-rw-r--r-- | src/alf/index.tsx | 92 | ||||
-rw-r--r-- | src/alf/themes.ts | 108 | ||||
-rw-r--r-- | src/alf/tokens.ts | 100 | ||||
-rw-r--r-- | src/alf/types.ts | 16 | ||||
-rw-r--r-- | src/alf/util/platform.ts | 25 | ||||
-rw-r--r-- | src/alf/util/useColorModeTheme.ts | 10 |
8 files changed, 921 insertions, 0 deletions
diff --git a/src/alf/README.md b/src/alf/README.md new file mode 100644 index 000000000..aa31bcf98 --- /dev/null +++ b/src/alf/README.md @@ -0,0 +1,56 @@ +# Application Layout Framework (ALF) + +A set of UI primitives and components. + +## Usage + +Naming conventions follow Tailwind — delimited with a `_` instead of `-` to +enable object access — with a couple exceptions: + +**Spacing** + +Uses "t-shirt" sizes `xxs`, `xs`, `sm`, `md`, `lg`, `xl` and `xxl` instead of +increments of 4px. We only use a few common spacings, and otherwise typically +rely on many one-off values. + +**Text Size** + +Uses "t-shirt" sizes `xxs`, `xs`, `sm`, `md`, `lg`, `xl` and `xxl` to match our +type scale. + +**Line Height** + +The text size atoms also apply a line-height with the same value as the size, +for a 1:1 ratio. `tight` and `normal` are retained for use in the few places +where we need leading. + +### Atoms + +An (mostly-complete) set of style definitions that match Tailwind CSS selectors. +These are static and reused throughout the app. + +```tsx +import { atoms } from '#/alf' + +<View style={[atoms.flex_row]} /> +``` + +### Theme + +Any values that rely on the theme, namely colors. + +```tsx +const t = useTheme() + +<View style={[atoms.flex_row, t.atoms.bg]} /> +``` + +### Breakpoints + +```tsx +const b = useBreakpoints() + +if (b.gtMobile) { + // render tablet or desktop UI +} +``` diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts new file mode 100644 index 000000000..c142f5f71 --- /dev/null +++ b/src/alf/atoms.ts @@ -0,0 +1,514 @@ +import * as tokens from '#/alf/tokens' + +export const atoms = { + /* + * Positioning + */ + absolute: { + position: 'absolute', + }, + relative: { + position: 'relative', + }, + inset_0: { + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + z_10: { + zIndex: 10, + }, + z_20: { + zIndex: 20, + }, + z_30: { + zIndex: 30, + }, + z_40: { + zIndex: 40, + }, + z_50: { + zIndex: 50, + }, + + /* + * Width + */ + w_full: { + width: '100%', + }, + h_full: { + height: '100%', + }, + + /* + * Border radius + */ + rounded_sm: { + borderRadius: tokens.borderRadius.sm, + }, + rounded_md: { + borderRadius: tokens.borderRadius.md, + }, + rounded_full: { + borderRadius: tokens.borderRadius.full, + }, + + /* + * Flex + */ + gap_xxs: { + gap: tokens.space.xxs, + }, + gap_xs: { + gap: tokens.space.xs, + }, + gap_sm: { + gap: tokens.space.sm, + }, + gap_md: { + gap: tokens.space.md, + }, + gap_lg: { + gap: tokens.space.lg, + }, + gap_xl: { + gap: tokens.space.xl, + }, + gap_xxl: { + gap: tokens.space.xxl, + }, + flex: { + display: 'flex', + }, + flex_row: { + flexDirection: 'row', + }, + flex_wrap: { + flexWrap: 'wrap', + }, + flex_1: { + flex: 1, + }, + flex_grow: { + flexGrow: 1, + }, + flex_shrink: { + flexShrink: 1, + }, + justify_center: { + justifyContent: 'center', + }, + justify_between: { + justifyContent: 'space-between', + }, + justify_end: { + justifyContent: 'flex-end', + }, + align_center: { + alignItems: 'center', + }, + align_start: { + alignItems: 'flex-start', + }, + align_end: { + alignItems: 'flex-end', + }, + + /* + * Text + */ + text_center: { + textAlign: 'center', + }, + text_right: { + textAlign: 'right', + }, + text_xxs: { + fontSize: tokens.fontSize.xxs, + lineHeight: tokens.fontSize.xxs, + }, + text_xs: { + fontSize: tokens.fontSize.xs, + lineHeight: tokens.fontSize.xs, + }, + text_sm: { + fontSize: tokens.fontSize.sm, + lineHeight: tokens.fontSize.sm, + }, + text_md: { + fontSize: tokens.fontSize.md, + lineHeight: tokens.fontSize.md, + }, + text_lg: { + fontSize: tokens.fontSize.lg, + lineHeight: tokens.fontSize.lg, + }, + text_xl: { + fontSize: tokens.fontSize.xl, + lineHeight: tokens.fontSize.xl, + }, + text_xxl: { + fontSize: tokens.fontSize.xxl, + lineHeight: tokens.fontSize.xxl, + }, + leading_tight: { + lineHeight: 1.25, + }, + leading_normal: { + lineHeight: 1.5, + }, + font_normal: { + fontWeight: tokens.fontWeight.normal, + }, + font_semibold: { + fontWeight: tokens.fontWeight.semibold, + }, + font_bold: { + fontWeight: tokens.fontWeight.bold, + }, + + /* + * Border + */ + border: { + borderWidth: 1, + }, + border_t: { + borderTopWidth: 1, + }, + border_b: { + borderBottomWidth: 1, + }, + + /* + * Padding + */ + p_xxs: { + padding: tokens.space.xxs, + }, + p_xs: { + padding: tokens.space.xs, + }, + p_sm: { + padding: tokens.space.sm, + }, + p_md: { + padding: tokens.space.md, + }, + p_lg: { + padding: tokens.space.lg, + }, + p_xl: { + padding: tokens.space.xl, + }, + p_xxl: { + padding: tokens.space.xxl, + }, + px_xxs: { + paddingLeft: tokens.space.xxs, + paddingRight: tokens.space.xxs, + }, + px_xs: { + paddingLeft: tokens.space.xs, + paddingRight: tokens.space.xs, + }, + px_sm: { + paddingLeft: tokens.space.sm, + paddingRight: tokens.space.sm, + }, + px_md: { + paddingLeft: tokens.space.md, + paddingRight: tokens.space.md, + }, + px_lg: { + paddingLeft: tokens.space.lg, + paddingRight: tokens.space.lg, + }, + px_xl: { + paddingLeft: tokens.space.xl, + paddingRight: tokens.space.xl, + }, + px_xxl: { + paddingLeft: tokens.space.xxl, + paddingRight: tokens.space.xxl, + }, + py_xxs: { + paddingTop: tokens.space.xxs, + paddingBottom: tokens.space.xxs, + }, + py_xs: { + paddingTop: tokens.space.xs, + paddingBottom: tokens.space.xs, + }, + py_sm: { + paddingTop: tokens.space.sm, + paddingBottom: tokens.space.sm, + }, + py_md: { + paddingTop: tokens.space.md, + paddingBottom: tokens.space.md, + }, + py_lg: { + paddingTop: tokens.space.lg, + paddingBottom: tokens.space.lg, + }, + py_xl: { + paddingTop: tokens.space.xl, + paddingBottom: tokens.space.xl, + }, + py_xxl: { + paddingTop: tokens.space.xxl, + paddingBottom: tokens.space.xxl, + }, + pt_xxs: { + paddingTop: tokens.space.xxs, + }, + pt_xs: { + paddingTop: tokens.space.xs, + }, + pt_sm: { + paddingTop: tokens.space.sm, + }, + pt_md: { + paddingTop: tokens.space.md, + }, + pt_lg: { + paddingTop: tokens.space.lg, + }, + pt_xl: { + paddingTop: tokens.space.xl, + }, + pt_xxl: { + paddingTop: tokens.space.xxl, + }, + pb_xxs: { + paddingBottom: tokens.space.xxs, + }, + pb_xs: { + paddingBottom: tokens.space.xs, + }, + pb_sm: { + paddingBottom: tokens.space.sm, + }, + pb_md: { + paddingBottom: tokens.space.md, + }, + pb_lg: { + paddingBottom: tokens.space.lg, + }, + pb_xl: { + paddingBottom: tokens.space.xl, + }, + pb_xxl: { + paddingBottom: tokens.space.xxl, + }, + pl_xxs: { + paddingLeft: tokens.space.xxs, + }, + pl_xs: { + paddingLeft: tokens.space.xs, + }, + pl_sm: { + paddingLeft: tokens.space.sm, + }, + pl_md: { + paddingLeft: tokens.space.md, + }, + pl_lg: { + paddingLeft: tokens.space.lg, + }, + pl_xl: { + paddingLeft: tokens.space.xl, + }, + pl_xxl: { + paddingLeft: tokens.space.xxl, + }, + pr_xxs: { + paddingRight: tokens.space.xxs, + }, + pr_xs: { + paddingRight: tokens.space.xs, + }, + pr_sm: { + paddingRight: tokens.space.sm, + }, + pr_md: { + paddingRight: tokens.space.md, + }, + pr_lg: { + paddingRight: tokens.space.lg, + }, + pr_xl: { + paddingRight: tokens.space.xl, + }, + pr_xxl: { + paddingRight: tokens.space.xxl, + }, + + /* + * Margin + */ + m_xxs: { + margin: tokens.space.xxs, + }, + m_xs: { + margin: tokens.space.xs, + }, + m_sm: { + margin: tokens.space.sm, + }, + m_md: { + margin: tokens.space.md, + }, + m_lg: { + margin: tokens.space.lg, + }, + m_xl: { + margin: tokens.space.xl, + }, + m_xxl: { + margin: tokens.space.xxl, + }, + mx_xxs: { + marginLeft: tokens.space.xxs, + marginRight: tokens.space.xxs, + }, + mx_xs: { + marginLeft: tokens.space.xs, + marginRight: tokens.space.xs, + }, + mx_sm: { + marginLeft: tokens.space.sm, + marginRight: tokens.space.sm, + }, + mx_md: { + marginLeft: tokens.space.md, + marginRight: tokens.space.md, + }, + mx_lg: { + marginLeft: tokens.space.lg, + marginRight: tokens.space.lg, + }, + mx_xl: { + marginLeft: tokens.space.xl, + marginRight: tokens.space.xl, + }, + mx_xxl: { + marginLeft: tokens.space.xxl, + marginRight: tokens.space.xxl, + }, + my_xxs: { + marginTop: tokens.space.xxs, + marginBottom: tokens.space.xxs, + }, + my_xs: { + marginTop: tokens.space.xs, + marginBottom: tokens.space.xs, + }, + my_sm: { + marginTop: tokens.space.sm, + marginBottom: tokens.space.sm, + }, + my_md: { + marginTop: tokens.space.md, + marginBottom: tokens.space.md, + }, + my_lg: { + marginTop: tokens.space.lg, + marginBottom: tokens.space.lg, + }, + my_xl: { + marginTop: tokens.space.xl, + marginBottom: tokens.space.xl, + }, + my_xxl: { + marginTop: tokens.space.xxl, + marginBottom: tokens.space.xxl, + }, + mt_xxs: { + marginTop: tokens.space.xxs, + }, + mt_xs: { + marginTop: tokens.space.xs, + }, + mt_sm: { + marginTop: tokens.space.sm, + }, + mt_md: { + marginTop: tokens.space.md, + }, + mt_lg: { + marginTop: tokens.space.lg, + }, + mt_xl: { + marginTop: tokens.space.xl, + }, + mt_xxl: { + marginTop: tokens.space.xxl, + }, + mb_xxs: { + marginBottom: tokens.space.xxs, + }, + mb_xs: { + marginBottom: tokens.space.xs, + }, + mb_sm: { + marginBottom: tokens.space.sm, + }, + mb_md: { + marginBottom: tokens.space.md, + }, + mb_lg: { + marginBottom: tokens.space.lg, + }, + mb_xl: { + marginBottom: tokens.space.xl, + }, + mb_xxl: { + marginBottom: tokens.space.xxl, + }, + ml_xxs: { + marginLeft: tokens.space.xxs, + }, + ml_xs: { + marginLeft: tokens.space.xs, + }, + ml_sm: { + marginLeft: tokens.space.sm, + }, + ml_md: { + marginLeft: tokens.space.md, + }, + ml_lg: { + marginLeft: tokens.space.lg, + }, + ml_xl: { + marginLeft: tokens.space.xl, + }, + ml_xxl: { + marginLeft: tokens.space.xxl, + }, + mr_xxs: { + marginRight: tokens.space.xxs, + }, + mr_xs: { + marginRight: tokens.space.xs, + }, + mr_sm: { + marginRight: tokens.space.sm, + }, + mr_md: { + marginRight: tokens.space.md, + }, + mr_lg: { + marginRight: tokens.space.lg, + }, + mr_xl: { + marginRight: tokens.space.xl, + }, + mr_xxl: { + marginRight: tokens.space.xxl, + }, +} as const diff --git a/src/alf/index.tsx b/src/alf/index.tsx new file mode 100644 index 000000000..1daa0bfed --- /dev/null +++ b/src/alf/index.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import {Dimensions} from 'react-native' +import * as themes from '#/alf/themes' + +export * as tokens from '#/alf/tokens' +export {atoms} from '#/alf/atoms' +export * from '#/alf/util/platform' + +type BreakpointName = keyof typeof breakpoints + +/* + * Breakpoints + */ +const breakpoints: { + [key: string]: number +} = { + gtMobile: 800, + gtTablet: 1200, +} +function getActiveBreakpoints({width}: {width: number}) { + const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter( + breakpoint => width >= breakpoints[breakpoint], + ) + + return { + active: active[active.length - 1], + gtMobile: active.includes('gtMobile'), + gtTablet: active.includes('gtTablet'), + } +} + +/* + * Context + */ +export const Context = React.createContext<{ + themeName: themes.ThemeName + theme: themes.Theme + breakpoints: { + active: BreakpointName | undefined + gtMobile: boolean + gtTablet: boolean + } +}>({ + themeName: 'light', + theme: themes.light, + breakpoints: { + active: undefined, + gtMobile: false, + gtTablet: false, + }, +}) + +export function ThemeProvider({ + children, + theme: themeName, +}: React.PropsWithChildren<{theme: themes.ThemeName}>) { + const theme = themes[themeName] + const [breakpoints, setBreakpoints] = React.useState(() => + getActiveBreakpoints({width: Dimensions.get('window').width}), + ) + + React.useEffect(() => { + const listener = Dimensions.addEventListener('change', ({window}) => { + const bp = getActiveBreakpoints({width: window.width}) + if (bp.active !== breakpoints.active) setBreakpoints(bp) + }) + + return listener.remove + }, [breakpoints, setBreakpoints]) + + return ( + <Context.Provider + value={React.useMemo( + () => ({ + themeName: themeName, + theme: theme, + breakpoints, + }), + [theme, themeName, breakpoints], + )}> + {children} + </Context.Provider> + ) +} + +export function useTheme() { + return React.useContext(Context).theme +} + +export function useBreakpoints() { + return React.useContext(Context).breakpoints +} diff --git a/src/alf/themes.ts b/src/alf/themes.ts new file mode 100644 index 000000000..aae5c5893 --- /dev/null +++ b/src/alf/themes.ts @@ -0,0 +1,108 @@ +import * as tokens from '#/alf/tokens' +import type {Mutable} from '#/alf/types' + +export type ThemeName = 'light' | 'dark' +export type ReadonlyTheme = typeof light +export type Theme = Mutable<ReadonlyTheme> + +export type Palette = { + primary: string + positive: string + negative: string +} + +export const lightPalette: Palette = { + primary: tokens.color.blue_500, + positive: tokens.color.green_500, + negative: tokens.color.red_500, +} as const + +export const darkPalette: Palette = { + primary: tokens.color.blue_500, + positive: tokens.color.green_400, + negative: tokens.color.red_400, +} as const + +export const light = { + palette: lightPalette, + atoms: { + text: { + color: tokens.color.gray_1000, + }, + text_contrast_700: { + color: tokens.color.gray_700, + }, + text_contrast_500: { + color: tokens.color.gray_500, + }, + text_inverted: { + color: tokens.color.white, + }, + bg: { + backgroundColor: tokens.color.white, + }, + bg_contrast_100: { + backgroundColor: tokens.color.gray_100, + }, + bg_contrast_200: { + backgroundColor: tokens.color.gray_200, + }, + bg_contrast_300: { + backgroundColor: tokens.color.gray_300, + }, + bg_positive: { + backgroundColor: tokens.color.green_500, + }, + bg_negative: { + backgroundColor: tokens.color.red_400, + }, + border: { + borderColor: tokens.color.gray_200, + }, + border_contrast_500: { + borderColor: tokens.color.gray_500, + }, + }, +} + +export const dark: Theme = { + palette: darkPalette, + atoms: { + text: { + color: tokens.color.white, + }, + text_contrast_700: { + color: tokens.color.gray_300, + }, + text_contrast_500: { + color: tokens.color.gray_500, + }, + text_inverted: { + color: tokens.color.gray_1000, + }, + bg: { + backgroundColor: tokens.color.gray_1000, + }, + bg_contrast_100: { + backgroundColor: tokens.color.gray_900, + }, + bg_contrast_200: { + backgroundColor: tokens.color.gray_800, + }, + bg_contrast_300: { + backgroundColor: tokens.color.gray_700, + }, + bg_positive: { + backgroundColor: tokens.color.green_400, + }, + bg_negative: { + backgroundColor: tokens.color.red_400, + }, + border: { + borderColor: tokens.color.gray_800, + }, + border_contrast_500: { + borderColor: tokens.color.gray_500, + }, + }, +} diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts new file mode 100644 index 000000000..4034e0deb --- /dev/null +++ b/src/alf/tokens.ts @@ -0,0 +1,100 @@ +const BLUE_HUE = 211 +const GRAYSCALE_SATURATION = 22 + +export const color = { + white: '#FFFFFF', + + gray_0: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 100%)`, + gray_100: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 95%)`, + gray_200: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 85%)`, + gray_300: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 75%)`, + gray_400: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 65%)`, + gray_500: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 55%)`, + gray_600: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 45%)`, + gray_700: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 35%)`, + gray_800: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 25%)`, + gray_900: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 15%)`, + gray_1000: `hsl(${BLUE_HUE}, ${GRAYSCALE_SATURATION}%, 5%)`, + + blue_0: `hsl(${BLUE_HUE}, 99%, 100%)`, + blue_100: `hsl(${BLUE_HUE}, 99%, 93%)`, + blue_200: `hsl(${BLUE_HUE}, 99%, 83%)`, + blue_300: `hsl(${BLUE_HUE}, 99%, 73%)`, + blue_400: `hsl(${BLUE_HUE}, 99%, 63%)`, + blue_500: `hsl(${BLUE_HUE}, 99%, 53%)`, + blue_600: `hsl(${BLUE_HUE}, 99%, 43%)`, + blue_700: `hsl(${BLUE_HUE}, 99%, 33%)`, + blue_800: `hsl(${BLUE_HUE}, 99%, 23%)`, + blue_900: `hsl(${BLUE_HUE}, 99%, 13%)`, + blue_1000: `hsl(${BLUE_HUE}, 99%, 8%)`, + + green_0: `hsl(130, 60%, 100%)`, + green_100: `hsl(130, 60%, 95%)`, + green_200: `hsl(130, 60%, 85%)`, + green_300: `hsl(130, 60%, 75%)`, + green_400: `hsl(130, 60%, 65%)`, + green_500: `hsl(130, 60%, 55%)`, + green_600: `hsl(130, 60%, 45%)`, + green_700: `hsl(130, 60%, 35%)`, + green_800: `hsl(130, 60%, 25%)`, + green_900: `hsl(130, 60%, 15%)`, + green_1000: `hsl(130, 60%, 5%)`, + + red_0: `hsl(349, 96%, 100%)`, + red_100: `hsl(349, 96%, 95%)`, + red_200: `hsl(349, 96%, 85%)`, + red_300: `hsl(349, 96%, 75%)`, + red_400: `hsl(349, 96%, 65%)`, + red_500: `hsl(349, 96%, 55%)`, + red_600: `hsl(349, 96%, 45%)`, + red_700: `hsl(349, 96%, 35%)`, + red_800: `hsl(349, 96%, 25%)`, + red_900: `hsl(349, 96%, 15%)`, + red_1000: `hsl(349, 96%, 5%)`, +} as const + +export const space = { + xxs: 2, + xs: 4, + sm: 8, + md: 12, + lg: 18, + xl: 24, + xxl: 32, +} as const + +export const fontSize = { + xxs: 10, + xs: 12, + sm: 14, + md: 16, + lg: 18, + xl: 22, + xxl: 26, +} as const + +// TODO test +export const lineHeight = { + none: 1, + normal: 1.5, + relaxed: 1.625, +} as const + +export const borderRadius = { + sm: 8, + md: 12, + full: 999, +} as const + +export const fontWeight = { + normal: '400', + semibold: '600', + bold: '900', +} as const + +export type Color = keyof typeof color +export type Space = keyof typeof space +export type FontSize = keyof typeof fontSize +export type LineHeight = keyof typeof lineHeight +export type BorderRadius = keyof typeof borderRadius +export type FontWeight = keyof typeof fontWeight diff --git a/src/alf/types.ts b/src/alf/types.ts new file mode 100644 index 000000000..76ac05d40 --- /dev/null +++ b/src/alf/types.ts @@ -0,0 +1,16 @@ +type LiteralToCommon<T extends PropertyKey> = T extends number + ? number + : T extends string + ? string + : T extends symbol + ? symbol + : never + +/** + * @see https://stackoverflow.com/questions/68249999/use-as-const-in-typescript-without-adding-readonly-modifiers + */ +export type Mutable<T> = { + -readonly [K in keyof T]: T[K] extends PropertyKey + ? LiteralToCommon<T[K]> + : Mutable<T[K]> +} diff --git a/src/alf/util/platform.ts b/src/alf/util/platform.ts new file mode 100644 index 000000000..544f5480b --- /dev/null +++ b/src/alf/util/platform.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/alf/util/useColorModeTheme.ts b/src/alf/util/useColorModeTheme.ts new file mode 100644 index 000000000..79cebc139 --- /dev/null +++ b/src/alf/util/useColorModeTheme.ts @@ -0,0 +1,10 @@ +import {useColorScheme} from 'react-native' + +import * as persisted from '#/state/persisted' + +export function useColorModeTheme( + theme: persisted.Schema['colorMode'], +): 'light' | 'dark' { + const colorScheme = useColorScheme() + return (theme === 'system' ? colorScheme : theme) || 'light' +} |