diff --git a/src/App.native.tsx b/src/App.native.tsx index 39485ee24f..7042c6e098 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -41,7 +41,10 @@ import { import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import * as persisted from '#/state/persisted' import {Splash} from '#/Splash' -import {Provider as PortalProvider, Outlet as PortalOutlet} from '#/view/com/Portal' +import { + Provider as PortalProvider, + Outlet as PortalOutlet, +} from '#/components/Portal' SplashScreen.preventAutoHideAsync() diff --git a/src/App.web.tsx b/src/App.web.tsx index 5760b0d4a2..546f5614df 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -30,7 +30,10 @@ import { } from 'state/session' import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread' import * as persisted from '#/state/persisted' -import {Provider as PortalProvider, Outlet as PortalOutlet} from '#/view/com/Portal' +import { + Provider as PortalProvider, + Outlet as PortalOutlet, +} from '#/components/Portal' function InnerApp() { const {isInitialLoad, currentAccount} = useSession() diff --git a/src/alf/themes.ts b/src/alf/themes.ts index a4fe02fc1c..103693b0b3 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -8,11 +8,8 @@ export type ReadonlyPalette = typeof lightPalette export type Palette = Mutable export const lightPalette = { - white: '#FFFFFF', - black: '#080B12', - primary: tokens.color.blue_500, - positive: tokens.color.green_500, - negative: tokens.color.red_500, + white: tokens.color.white, + black: tokens.color.black, contrast_25: tokens.color.gray_25, contrast_50: tokens.color.gray_50, @@ -25,14 +22,47 @@ export const lightPalette = { contrast_700: tokens.color.gray_700, contrast_800: tokens.color.gray_800, contrast_900: tokens.color.gray_900, + + primary_25: tokens.color.blue_25, + primary_50: tokens.color.blue_50, + primary_100: tokens.color.blue_100, + primary_200: tokens.color.blue_200, + primary_300: tokens.color.blue_300, + primary_400: tokens.color.blue_400, + primary_500: tokens.color.blue_500, + primary_600: tokens.color.blue_600, + primary_700: tokens.color.blue_700, + primary_800: tokens.color.blue_800, + primary_900: tokens.color.blue_900, + + positive_25: tokens.color.green_25, + positive_50: tokens.color.green_50, + positive_100: tokens.color.green_100, + positive_200: tokens.color.green_200, + positive_300: tokens.color.green_300, + positive_400: tokens.color.green_400, + positive_500: tokens.color.green_500, + positive_600: tokens.color.green_600, + positive_700: tokens.color.green_700, + positive_800: tokens.color.green_800, + positive_900: tokens.color.green_900, + + negative_25: tokens.color.red_25, + negative_50: tokens.color.red_50, + negative_100: tokens.color.red_100, + negative_200: tokens.color.red_200, + negative_300: tokens.color.red_300, + negative_400: tokens.color.red_400, + negative_500: tokens.color.red_500, + negative_600: tokens.color.red_600, + negative_700: tokens.color.red_700, + negative_800: tokens.color.red_800, + negative_900: tokens.color.red_900, } as const export const darkPalette: Palette = { - white: '#FFFFFF', - black: '#080B12', - primary: tokens.color.blue_500, - positive: tokens.color.green_500, - negative: tokens.color.red_500, + white: tokens.color.white, + black: tokens.color.black, contrast_25: tokens.color.gray_900, contrast_50: tokens.color.gray_800, @@ -45,6 +75,42 @@ export const darkPalette: Palette = { contrast_700: tokens.color.gray_100, contrast_800: tokens.color.gray_50, contrast_900: tokens.color.gray_25, + + primary_25: tokens.color.blue_25, + primary_50: tokens.color.blue_50, + primary_100: tokens.color.blue_100, + primary_200: tokens.color.blue_200, + primary_300: tokens.color.blue_300, + primary_400: tokens.color.blue_400, + primary_500: tokens.color.blue_500, + primary_600: tokens.color.blue_600, + primary_700: tokens.color.blue_700, + primary_800: tokens.color.blue_800, + primary_900: tokens.color.blue_900, + + positive_25: tokens.color.green_25, + positive_50: tokens.color.green_50, + positive_100: tokens.color.green_100, + positive_200: tokens.color.green_200, + positive_300: tokens.color.green_300, + positive_400: tokens.color.green_400, + positive_500: tokens.color.green_500, + positive_600: tokens.color.green_600, + positive_700: tokens.color.green_700, + positive_800: tokens.color.green_800, + positive_900: tokens.color.green_900, + + negative_25: tokens.color.red_25, + negative_50: tokens.color.red_50, + negative_100: tokens.color.red_100, + negative_200: tokens.color.red_200, + negative_300: tokens.color.red_300, + negative_400: tokens.color.red_400, + negative_500: tokens.color.red_500, + negative_600: tokens.color.red_600, + negative_700: tokens.color.red_700, + negative_800: tokens.color.red_800, + negative_900: tokens.color.red_900, } as const export const light = { @@ -52,7 +118,7 @@ export const light = { palette: lightPalette, atoms: { text: { - color: tokens.color.gray_900, + color: lightPalette.black, }, text_contrast_600: { color: lightPalette.contrast_600, @@ -110,7 +176,7 @@ export const dim: Theme = { color: darkPalette.contrast_400, }, text_inverted: { - color: darkPalette.contrast_900, + color: darkPalette.black, }, bg: { backgroundColor: darkPalette.contrast_25, @@ -156,7 +222,7 @@ export const dark: Theme = { color: darkPalette.contrast_400, }, text_inverted: { - color: tokens.color.gray_900, + color: darkPalette.black, }, bg: { backgroundColor: darkPalette.black, diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts index 35189e7b88..db402840f8 100644 --- a/src/alf/tokens.ts +++ b/src/alf/tokens.ts @@ -1,4 +1,7 @@ export const color = { + white: '#FFFFFF', + black: '#080B12', + gray_25: `#FCFCFD`, gray_50: `#F9FAFB`, gray_100: `#F3F4F6`, diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000000..9d36892a55 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,434 @@ +import React from 'react' +import { + Pressable, + Text, + PressableProps, + TextProps, + ViewStyle, + AccessibilityProps, +} from 'react-native' + +import {useTheme, atoms, tokens, web, native} from '#/alf' + +export type ButtonVariant = 'solid' | 'outline' | 'ghost' +export type ButtonColor = 'primary' | 'secondary' | 'negative' +export type ButtonSize = 'small' | 'large' +export type VariantProps = { + /** + * The style variation of the button + */ + variant?: ButtonVariant + /** + * The color of the button + */ + color?: ButtonColor + /** + * The size of the button + */ + size?: ButtonSize +} + +export type ButtonProps = Omit< + PressableProps, + 'children' | 'style' | 'accessibilityLabel' | 'accessibilityHint' +> & + VariantProps & { + children: + | ((props: { + state: { + pressed: boolean + hovered: boolean + focused: boolean + } + props: VariantProps & { + disabled?: boolean + } + }) => React.ReactNode) + | React.ReactNode + | string + accessibilityLabel: Required['accessibilityLabel'] + accessibilityHint: Required['accessibilityHint'] + } +export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} + +export function Button({ + children, + variant, + color, + size, + accessibilityLabel, + accessibilityHint, + disabled = false, + ...rest +}: ButtonProps) { + const t = useTheme() + const [state, setState] = React.useState({ + pressed: false, + hovered: false, + focused: false, + }) + + const onPressIn = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: true, + })) + }, [setState]) + const onPressOut = React.useCallback(() => { + setState(s => ({ + ...s, + pressed: false, + })) + }, [setState]) + const onHoverIn = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: true, + })) + }, [setState]) + const onHoverOut = React.useCallback(() => { + setState(s => ({ + ...s, + hovered: false, + })) + }, [setState]) + const onFocus = React.useCallback(() => { + setState(s => ({ + ...s, + focused: true, + })) + }, [setState]) + const onBlur = React.useCallback(() => { + setState(s => ({ + ...s, + focused: false, + })) + }, [setState]) + + const {baseStyles, hoverStyles} = React.useMemo(() => { + const baseStyles: ViewStyle[] = [] + const hoverStyles: ViewStyle[] = [] + const light = t.name === 'light' + + if (color === 'primary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.primary_500, + }) + hoverStyles.push({ + backgroundColor: t.palette.primary_600, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.primary_700, + }) + } + } else if (variant === 'outline') { + baseStyles.push(atoms.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(atoms.border, { + borderColor: tokens.color.blue_500, + }) + hoverStyles.push(atoms.border, { + backgroundColor: light + ? t.palette.primary_100 + : t.palette.primary_900, + }) + } else { + baseStyles.push(atoms.border, { + borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? t.palette.primary_100 + : t.palette.primary_900, + }) + } + } + } else if (color === 'secondary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: light + ? tokens.color.gray_200 + : tokens.color.gray_800, + }) + hoverStyles.push({ + backgroundColor: light + ? tokens.color.gray_300 + : tokens.color.gray_900, + }) + } else { + baseStyles.push({ + backgroundColor: light + ? tokens.color.gray_300 + : tokens.color.gray_900, + }) + } + } else if (variant === 'outline') { + baseStyles.push(atoms.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(atoms.border, { + borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500, + }) + hoverStyles.push(atoms.border, t.atoms.bg_contrast_50) + } else { + baseStyles.push(atoms.border, { + borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light + ? tokens.color.gray_100 + : tokens.color.gray_800, + }) + } + } + } else if (color === 'negative') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.negative_500, + }) + hoverStyles.push({ + backgroundColor: t.palette.negative_600, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.negative_700, + }) + } + } else if (variant === 'outline') { + baseStyles.push(atoms.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(atoms.border, { + borderColor: t.palette.negative_600, + }) + hoverStyles.push(atoms.border, { + backgroundColor: light ? t.palette.negative_50 : '#2D0614', // darker red + }) + } else { + baseStyles.push(atoms.border, { + borderColor: light + ? t.palette.negative_200 + : t.palette.negative_900, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push(t.atoms.bg) + hoverStyles.push({ + backgroundColor: light ? t.palette.negative_50 : '#2D0614', // darker red + }) + } + } + } + + if (size === 'large') { + baseStyles.push( + {paddingVertical: 15}, + atoms.px_2xl, + atoms.rounded_sm, + atoms.gap_sm, + ) + } else if (size === 'small') { + baseStyles.push( + {paddingVertical: 9}, + atoms.px_md, + atoms.rounded_sm, + atoms.gap_xs, + ) + } + + return { + baseStyles, + hoverStyles, + } + }, [t, variant, color, size, disabled]) + + const childProps = React.useMemo( + () => ({ + state, + props: { + variant, + color, + size, + disabled: disabled || false, + }, + }), + [state, variant, color, size, disabled], + ) + + return ( + + {typeof children === 'string' ? ( + + {children} + + ) : typeof children === 'function' ? ( + children(childProps) + ) : ( + children + )} + + ) +} + +export function ButtonText({ + children, + style, + variant, + color, + size, + disabled, + ...rest +}: ButtonTextProps) { + const t = useTheme() + + const textStyles = React.useMemo(() => { + const baseStyles = [] + const light = t.name === 'light' + + if (color === 'primary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({color: t.palette.white}) + } else { + baseStyles.push({color: t.palette.white, opacity: 0.5}) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({ + color: light ? t.palette.primary_600 : t.palette.primary_500, + }) + } else { + baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({color: t.palette.primary_600}) + } else { + baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) + } + } + } else if (color === 'secondary') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + color: light ? tokens.color.gray_700 : tokens.color.gray_100, + }) + } else { + baseStyles.push({ + color: light ? tokens.color.gray_400 : tokens.color.gray_700, + }) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({ + color: light ? tokens.color.gray_600 : tokens.color.gray_300, + }) + } else { + baseStyles.push({ + color: light ? tokens.color.gray_400 : tokens.color.gray_700, + }) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({ + color: light ? tokens.color.gray_600 : tokens.color.gray_300, + }) + } else { + baseStyles.push({ + color: light ? tokens.color.gray_400 : tokens.color.gray_600, + }) + } + } + } else if (color === 'negative') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({color: t.palette.white}) + } else { + baseStyles.push({color: t.palette.white, opacity: 0.5}) + } + } else if (variant === 'outline') { + if (!disabled) { + baseStyles.push({color: t.palette.negative_500}) + } else { + baseStyles.push({color: t.palette.negative_500, opacity: 0.5}) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({color: t.palette.negative_500}) + } else { + baseStyles.push({color: t.palette.negative_500, opacity: 0.5}) + } + } + } + + if (size === 'large') { + baseStyles.push( + atoms.text_md, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) + } else { + baseStyles.push( + atoms.text_md, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) + } + + return baseStyles + }, [t, variant, color, size, disabled]) + + return ( + + {children} + + ) +} diff --git a/src/view/com/Dialog/context.ts b/src/components/Dialog/context.ts similarity index 84% rename from src/view/com/Dialog/context.ts rename to src/components/Dialog/context.ts index 2330969933..76473aa243 100644 --- a/src/view/com/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -1,5 +1,5 @@ import React from 'react' -import {DialogContextProps, DialogControlProps} from '#/view/com/Dialog/types' +import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types' export const Context = React.createContext({ close: () => {}, diff --git a/src/view/com/Dialog/index.tsx b/src/components/Dialog/index.tsx similarity index 91% rename from src/view/com/Dialog/index.tsx rename to src/components/Dialog/index.tsx index 5a832e6fe4..4bc1338e0b 100644 --- a/src/view/com/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -3,17 +3,17 @@ import {View, Dimensions} from 'react-native' import BottomSheet, {BottomSheetBackdrop} from '@gorhom/bottom-sheet' import {useTheme, atoms as a} from '#/alf' -import {Portal} from '#/view/com/Portal' +import {Portal} from '#/components/Portal' import { DialogOuterProps, DialogControlProps, DialogInnerProps, -} from '#/view/com/Dialog/types' -import {Context} from '#/view/com/Dialog/context' +} from '#/components/Dialog/types' +import {Context} from '#/components/Dialog/context' -export {useDialogControl, useDialogContext} from '#/view/com/Dialog/context' -export * from '#/view/com/Dialog/types' +export {useDialogControl, useDialogContext} from '#/components/Dialog/context' +export * from '#/components/Dialog/types' export function Outer({ children, @@ -62,7 +62,7 @@ export function Outer({ {...props} /> )} - handleIndicatorStyle={{backgroundColor: t.palette.primary}} + handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} handleStyle={{display: 'none'}} onClose={onClose}> diff --git a/src/view/com/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx similarity index 92% rename from src/view/com/Dialog/index.web.tsx rename to src/components/Dialog/index.web.tsx index 37d4cebc85..8efccd55d7 100644 --- a/src/view/com/Dialog/index.web.tsx +++ b/src/components/Dialog/index.web.tsx @@ -4,15 +4,15 @@ import {FocusScope} from '@tamagui/focus-scope' import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated' import {useTheme, atoms as a, useBreakpoints, web} from '#/alf' -import {Text} from '#/view/com/Typography' -import {Portal} from '#/view/com/Portal' -import {Button} from '#/view/com/Button' +import {Text} from '#/components/Typography' +import {Portal} from '#/components/Portal' +import {Button} from '#/components/Button' -import {DialogOuterProps, DialogInnerProps} from '#/view/com/Dialog/types' -import {Context, useDialogContext} from '#/view/com/Dialog/context' +import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types' +import {Context, useDialogContext} from '#/components/Dialog/context' -export {useDialogControl, useDialogContext} from '#/view/com/Dialog/context' -export * from '#/view/com/Dialog/types' +export {useDialogControl, useDialogContext} from '#/components/Dialog/context' +export * from '#/components/Dialog/types' const stopPropagation = (e: any) => e.stopPropagation() diff --git a/src/view/com/Dialog/types.ts b/src/components/Dialog/types.ts similarity index 100% rename from src/view/com/Dialog/types.ts rename to src/components/Dialog/types.ts diff --git a/src/view/com/Link.tsx b/src/components/Link.tsx similarity index 96% rename from src/view/com/Link.tsx rename to src/components/Link.tsx index fd6bf5ca3c..8a2e825479 100644 --- a/src/view/com/Link.tsx +++ b/src/components/Link.tsx @@ -15,7 +15,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url' import {isWeb} from '#/platform/detection' import {useTheme, web} from '#/alf' -import {Button, ButtonProps} from '#/view/com/Button' +import {Button, ButtonProps} from '#/components/Button' import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' import { convertBskyAppUrlIfNeeded, @@ -157,10 +157,10 @@ export function Link({ {children as string} diff --git a/src/view/com/Portal.tsx b/src/components/Portal.tsx similarity index 100% rename from src/view/com/Portal.tsx rename to src/components/Portal.tsx diff --git a/src/view/com/Prompt.tsx b/src/components/Prompt.tsx similarity index 91% rename from src/view/com/Prompt.tsx rename to src/components/Prompt.tsx index baf7625828..b5841e7bb9 100644 --- a/src/view/com/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -3,12 +3,12 @@ import {View, PressableProps, LayoutChangeEvent} from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useTheme, atoms as a} from '#/alf' -import {H2, P} from '#/view/com/Typography' -import {Button} from '#/view/com/Button' +import {H2, P} from '#/components/Typography' +import {Button} from '#/components/Button' -import * as Dialog from '#/view/com/Dialog' +import * as Dialog from '#/components/Dialog' -export {useDialogControl as usePromptControl} from '#/view/com/Dialog' +export {useDialogControl as usePromptControl} from '#/components/Dialog' const Context = React.createContext<{ titleId: string @@ -102,7 +102,8 @@ export function Cancel({ const {close} = Dialog.useDialogContext() return ( + +

Buttons

- + + + ))}
- )} - - - - - - - - - - - - - - External - - -

External with custom children

- - - https://blueskyweb.xyz - - - Internal - - - - {({props}) => Link as a button} - + ))} + ) } diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index e0fcf0f811..b4a779e9d0 100644 --- a/src/view/screens/Storybook/Dialogs.tsx +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -2,10 +2,10 @@ import React from 'react' import {View} from 'react-native' import {atoms as a} from '#/alf' -import {Button} from '#/view/com/Button' -import {H3, P} from '#/view/com/Typography' -import * as Dialog from '#/view/com/Dialog' -import * as Prompt from '#/view/com/Prompt' +import {Button} from '#/components/Button' +import {H3, P} from '#/components/Typography' +import * as Dialog from '#/components/Dialog' +import * as Prompt from '#/components/Prompt' export function Dialogs() { const control = Dialog.useDialogControl() @@ -14,7 +14,8 @@ export function Dialogs() { return ( <>