From ca81dda6a49e87a9fb8c4979622218efeaa2f820 Mon Sep 17 00:00:00 2001 From: Thierry Skoda <6875849+thierryskoda@users.noreply.github.com> Date: Wed, 23 Oct 2024 09:12:16 -0400 Subject: [PATCH] feat: TextField design system (#1074) * add TextField and fix IconButton * delete old button --- design-system/Button/OldButton.tsx | 139 ------------ design-system/IconButton.tsx | 153 ------------- design-system/IconButton/IconButton.styles.ts | 50 +++++ design-system/IconButton/IconButton.tsx | 29 ++- design-system/Text/Text.presets.ts | 16 +- design-system/TextField/TextField.example.tsx | 102 +++++++++ design-system/TextField/TextField.props.tsx | 96 ++++++++ design-system/TextField/TextField.tsx | 205 ++++++++++++++++++ design-system/TextField/TextFieldSimple.tsx | 38 ++++ theme/border-radius.ts | 1 + theme/colorsDark.ts | 5 +- theme/colorsLight.ts | 5 +- theme/palette.ts | 10 +- theme/styles.ts | 2 + 14 files changed, 550 insertions(+), 301 deletions(-) delete mode 100644 design-system/Button/OldButton.tsx delete mode 100644 design-system/IconButton.tsx create mode 100644 design-system/TextField/TextField.example.tsx create mode 100644 design-system/TextField/TextField.props.tsx create mode 100644 design-system/TextField/TextField.tsx create mode 100644 design-system/TextField/TextFieldSimple.tsx diff --git a/design-system/Button/OldButton.tsx b/design-system/Button/OldButton.tsx deleted file mode 100644 index ae6241024..000000000 --- a/design-system/Button/OldButton.tsx +++ /dev/null @@ -1,139 +0,0 @@ -// import React, { useCallback, useMemo } from "react"; -// import { -// GestureResponderEvent, -// StyleProp, -// TextStyle, -// ViewStyle, -// } from "react-native"; -// import { Button as RNPButton } from "react-native-paper"; - -// import Picto from "../../components/Picto/Picto"; -// import { useAppTheme } from "../../theme/useAppTheme"; - -// export type IButtonAction = -// | "primary" -// | "secondary" -// | "positive" -// | "negative" -// | "danger" -// /** -// * @deprecated These action types are deprecated and will be removed in a future version. -// */ -// | "warning" -// | "text"; - -// export type IButtonVariant = "solid" | "link" | "outlined"; - -// export type IButtonProps = { -// title: string; -// action?: IButtonAction; -// variant?: IButtonVariant; -// onPress?: (event: GestureResponderEvent) => void; -// style?: StyleProp; -// textStyle?: StyleProp; -// picto?: string; -// hitSlop?: number; -// }; - -// export function OldButton({ -// title, -// onPress, -// variant = "solid", -// action = "primary", -// textStyle, -// picto, -// hitSlop, -// style, -// ...rest -// }: IButtonProps) { -// const { theme } = useAppTheme(); - -// const renderIcon = useCallback( -// ({ color, size }: { color: string; size: number }) => { -// if (!picto) { -// return null; -// } -// return ; -// }, -// [picto] -// ); - -// const mode = useMemo(() => { -// switch (variant) { -// case "solid": -// return "contained"; -// case "outlined": -// return "outlined"; -// case "link": -// default: -// return "text"; -// } -// }, [variant]); - -// const labelColor = useMemo(() => { -// return action === "text" -// ? theme.colors.text.primary -// : theme.colors.text.inverted.primary; -// }, [action, theme.colors]); - -// const backgroundColor = useMemo(() => { -// if (variant === "solid") { -// switch (action) { -// case "primary": -// return theme.colors.fill.primary; -// case "secondary": -// return theme.colors.fill.secondary; -// case "danger": -// return theme.colors.global.danger; -// default: -// return undefined; -// } -// } -// return undefined; -// }, [variant, action, theme.colors]); - -// const borderColor = useMemo(() => { -// if (variant === "outlined") { -// switch (action) { -// case "primary": -// return theme.colors.fill.primary; -// case "secondary": -// return theme.colors.actionSecondary; -// case "danger": -// return theme.colors.global.danger; -// default: -// return undefined; -// } -// } -// return undefined; -// }, [variant, action, theme.colors]); - -// const textColor = useMemo(() => { -// return action === "text" -// ? theme.colors.global.primary -// : theme.colors.text.inverted.primary; -// }, [action, theme.colors]); - -// return ( -// -// {title} -// -// ); -// } diff --git a/design-system/IconButton.tsx b/design-system/IconButton.tsx deleted file mode 100644 index 28e095c46..000000000 --- a/design-system/IconButton.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import React, { memo, useCallback, useMemo } from "react"; -import { GestureResponderEvent, Platform } from "react-native"; -import { - IconButtonProps as IRNPIconButtonProps, - IconButton as RNPIconButton, -} from "react-native-paper"; -import { IconSource as RNPIconSource } from "react-native-paper/lib/typescript/components/Icon"; -import { interpolate, useAnimatedStyle } from "react-native-reanimated"; - -import { TOUCHABLE_OPACITY_ACTIVE_OPACITY } from "./TouchableOpacity"; -import { AnimatedVStack } from "./VStack"; -import Picto from "../components/Picto/Picto"; -import { usePressInOut } from "../hooks/usePressInOut"; -import { $globalStyles } from "../theme/styles"; -import { useAppTheme } from "../theme/useAppTheme"; -import { Haptics } from "../utils/haptics"; - -type IIconButtonVariant = "solid" | "outlined" | "subtle"; - -type IIconButtonAction = - | "primary" - | "secondary" - | "positive" - | "negative" - | "warning" - | "text"; - -type IIconButtonProps = Omit & { - withHaptics?: boolean; - variant?: IIconButtonVariant; - action?: IIconButtonAction; -} & ( - | { - icon: - | React.ReactNode - | ((args: { size: number; color: string }) => React.ReactNode); - iconName?: never; - } - | { iconName: string; icon?: never } - ); - -export const IconButton = memo(function IconButton({ - style: styleOverride, - icon, - iconName, - onPress: onPressProps, - withHaptics = true, - variant, - action = "primary", - ...rest -}: IIconButtonProps) { - const { handlePressIn, handlePressOut, pressedInAV } = usePressInOut(); - const { theme } = useAppTheme(); - - const renderIcon: RNPIconSource = useCallback( - (props: { size: number; color: string }) => { - if (React.isValidElement(icon)) { - return icon; - } else if (typeof icon === "function") { - return icon(props); - } else if (typeof icon === "string") { - return ; - } - - if (iconName) { - return ; - } - - return null; - }, - [icon, iconName] - ); - - const animatedStyle = useAnimatedStyle(() => { - if (!pressedInAV) { - return { - opacity: 1, - }; - } - - return { - opacity: interpolate( - pressedInAV.value, - [0, 1], - [1, TOUCHABLE_OPACITY_ACTIVE_OPACITY] - ), - }; - }); - - const onPress = useCallback( - (e: GestureResponderEvent) => { - if (onPressProps) { - if (withHaptics) { - Haptics.lightImpactAsync(); - } - onPressProps(e); - } - }, - [withHaptics, onPressProps] - ); - - const mode = useMemo(() => { - switch (variant) { - case "outlined": - return "outlined"; - case "subtle": - return "contained-tonal"; - case "solid": - return "contained"; - default: - // Will just render the icon, no background or border - return undefined; - } - }, [variant]); - - const color = useMemo(() => { - switch (action) { - case "secondary": - return theme.colors.text.secondary; - case "positive": - return theme.colors.text.primary; - case "negative": - return theme.colors.global.danger; - case "warning": - return theme.colors.global.danger; - case "text": - return theme.colors.text.primary; - default: - return theme.colors.text.primary; - } - }, [action, theme.colors]); - - return ( - - - - ); -}); diff --git a/design-system/IconButton/IconButton.styles.ts b/design-system/IconButton/IconButton.styles.ts index 9cc1b29e3..44b822d73 100644 --- a/design-system/IconButton/IconButton.styles.ts +++ b/design-system/IconButton/IconButton.styles.ts @@ -144,3 +144,53 @@ export const getIconStyle = return style; }; + +export const getIconProps = + ({ + variant, + size, + action, + pressed = false, + disabled = false, + }: IconButtonStyleProps) => + (theme: Theme) => + // TODO: fix once we fixed IconProps + // : Partial + { + const { colors, spacing } = theme; + + const props: any = + // :Partial + {}; + + // Set icon size + const sizeMap = { + md: spacing.md, + lg: spacing.lg, + }; + + props.size = sizeMap[size]; + + if (disabled) { + props.color = colors.text.tertiary; + return props; + } + + if (action === "primary") { + switch (variant) { + case "fill": + props.color = colors.text.inverted.primary; + break; + + case "outline": + case "ghost": + props.color = colors.text.primary; + break; + + default: + break; + } + } + + return props; + }; diff --git a/design-system/IconButton/IconButton.tsx b/design-system/IconButton/IconButton.tsx index df326b152..460466a2c 100644 --- a/design-system/IconButton/IconButton.tsx +++ b/design-system/IconButton/IconButton.tsx @@ -6,7 +6,11 @@ import { useAppTheme } from "../../theme/useAppTheme"; import { Icon } from "../Icon/Icon"; import { Pressable } from "../Pressable"; import { IIconButtonProps } from "./IconButton.props"; -import { getIconButtonViewStyle, getIconStyle } from "./IconButton.styles"; +import { + getIconButtonViewStyle, + getIconProps, + getIconStyle, +} from "./IconButton.styles"; export function IconButton(props: IIconButtonProps) { const { @@ -65,6 +69,21 @@ export function IconButton(props: IIconButtonProps) { [themed, variant, size, action, disabled] ); + // For now until we fix Icon + const iconProps = useCallback( + ({ pressed }: PressableStateCallbackType) => + themed( + getIconProps({ + variant, + size, + action, + pressed, + disabled, + }) + ), + [themed, variant, size, action, disabled] + ); + return ( {({ pressed }) => { if (iconName) { - return ; + return ( + + ); } return icon; diff --git a/design-system/Text/Text.presets.ts b/design-system/Text/Text.presets.ts index 62c6e5e02..02dbfea07 100644 --- a/design-system/Text/Text.presets.ts +++ b/design-system/Text/Text.presets.ts @@ -13,7 +13,9 @@ export type IPresets = | "small" | "smaller" | "smallerBold" - | "bigBold"; + | "bigBold" + | "formHelper" + | "formLabel"; export const textPresets: Record> = { body: [textBaseStyle], @@ -27,4 +29,16 @@ export const textPresets: Record> = { smallerBold: [textBaseStyle, textSizeStyles.xs, textFontWeightStyles.bold], bigBold: [textBaseStyle, textSizeStyles.md, textFontWeightStyles.bold], + + formHelper: [ + textBaseStyle, + textSizeStyles.xs, + ({ colors }) => ({ color: colors.fill.secondary }), + ], + + formLabel: [ + textBaseStyle, + textSizeStyles.xs, + ({ colors }) => ({ color: colors.text.secondary }), + ], }; diff --git a/design-system/TextField/TextField.example.tsx b/design-system/TextField/TextField.example.tsx new file mode 100644 index 000000000..227bf1da5 --- /dev/null +++ b/design-system/TextField/TextField.example.tsx @@ -0,0 +1,102 @@ +import { memo } from "react"; + +import { Center } from "../Center"; +import { VStack } from "../VStack"; +import { TextField } from "./TextField"; +import { TextFieldSimple } from "./TextFieldSimple"; +import { IconButton } from "../IconButton/IconButton"; +import { Text } from "../Text/Text"; + +export const TextFieldExample = memo(function TextFieldExample() { + return ( + + { + return ( +
+ +
+ ); + }} + /> + ( +
+ +
+ )} + /> + ( +
+ +
+ )} + /> + + { + return ( +
+ +
+ ); + }} + /> + + + ( +
+ +
+ )} + /> + ( +
+ USD +
+ )} + /> + +
+ ); +}); diff --git a/design-system/TextField/TextField.props.tsx b/design-system/TextField/TextField.props.tsx new file mode 100644 index 000000000..7cc544e07 --- /dev/null +++ b/design-system/TextField/TextField.props.tsx @@ -0,0 +1,96 @@ +import { ComponentType } from "react"; +import { + ImageStyle, + StyleProp, + TextInputProps, + TextProps, + TextStyle, + ViewStyle, +} from "react-native"; + +import { ITextProps } from "../Text/Text.props"; + +export interface TextFieldAccessoryProps { + style: StyleProp; + status: TextFieldProps["status"]; + multiline: boolean; + editable: boolean; +} + +export interface TextFieldProps extends Omit { + /** + * A style modifier for different input states. + */ + status?: "error" | "disabled"; + /** + * The label text to display if not using `labelTx`. + */ + label?: ITextProps["text"]; + /** + * Label text which is looked up via i18n. + */ + labelTx?: ITextProps["tx"]; + /** + * Optional label options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + labelTxOptions?: ITextProps["txOptions"]; + /** + * Pass any additional props directly to the label Text component. + */ + LabelTextProps?: TextProps; + /** + * The helper text to display if not using `helperTx`. + */ + helper?: ITextProps["text"]; + /** + * Helper text which is looked up via i18n. + */ + helperTx?: ITextProps["tx"]; + /** + * Optional helper options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + helperTxOptions?: ITextProps["txOptions"]; + /** + * Pass any additional props directly to the helper Text component. + */ + HelperTextProps?: TextProps; + /** + * The placeholder text to display if not using `placeholderTx`. + */ + placeholder?: ITextProps["text"]; + /** + * Placeholder text which is looked up via i18n. + */ + placeholderTx?: ITextProps["tx"]; + /** + * Optional placeholder options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + placeholderTxOptions?: ITextProps["txOptions"]; + /** + * Optional input style override. + */ + style?: StyleProp; + /** + * Style overrides for the container + */ + containerStyle?: StyleProp; + /** + * Style overrides for the input wrapper + */ + inputWrapperStyle?: StyleProp; + /** + * An optional component to render on the right side of the input. + * Example: `RightAccessory={(props) => }` + * Note: It is a good idea to memoize this. + */ + RightAccessory?: ComponentType; + /** + * An optional component to render on the left side of the input. + * Example: `LeftAccessory={(props) => }` + * Note: It is a good idea to memoize this. + */ + LeftAccessory?: ComponentType; +} diff --git a/design-system/TextField/TextField.tsx b/design-system/TextField/TextField.tsx new file mode 100644 index 000000000..fdee07af9 --- /dev/null +++ b/design-system/TextField/TextField.tsx @@ -0,0 +1,205 @@ +import React, { forwardRef, Ref, useImperativeHandle, useRef } from "react"; +import { TextInput, TextStyle, ViewStyle } from "react-native"; + +import { $globalStyles } from "../../theme/styles"; +import { + ThemedStyle, + ThemedStyleArray, + useAppTheme, +} from "../../theme/useAppTheme"; +import { HStack } from "../HStack"; +import { Text } from "../Text/Text"; +import { textPresets } from "../Text/Text.presets"; +import { TouchableOpacity } from "../TouchableOpacity"; +import { VStack } from "../VStack"; +import { TextFieldProps } from "./TextField.props"; +import { translate } from "../../i18n"; + +export const TextField = forwardRef(function TextField( + props: TextFieldProps, + ref: Ref +) { + const { + labelTx, + label, + labelTxOptions, + placeholderTx, + placeholder, + placeholderTxOptions, + helper, + helperTx, + helperTxOptions, + status, + RightAccessory, + LeftAccessory, + HelperTextProps, + LabelTextProps, + style: $inputStyleOverride, + containerStyle: $containerStyleOverride, + inputWrapperStyle: $inputWrapperStyleOverride, + ...TextInputProps + } = props; + const input = useRef(null); + + const { themed, theme } = useAppTheme(); + + const disabled = TextInputProps.editable === false || status === "disabled"; + + const placeholderContent = placeholderTx + ? translate(placeholderTx, placeholderTxOptions) + : placeholder; + + const $containerStyles = [$containerStyle, $containerStyleOverride]; + + const $labelStyles = [ + $labelStyle, + disabled && { color: theme.colors.text.tertiary }, + LabelTextProps?.style, + ]; + + const $inputWrapperStyles = [ + $inputWrapperStyle, + status === "error" && { borderColor: theme.colors.global.caution }, + TextInputProps.multiline && { minHeight: 112 }, + LeftAccessory && { paddingStart: 0 }, + RightAccessory && { paddingEnd: 0 }, + $inputWrapperStyleOverride, + ]; + + const $inputStyles: ThemedStyleArray = [ + $inputStyle, + disabled && { color: theme.colors.text.tertiary }, + TextInputProps.multiline && { height: "auto" }, + $inputStyleOverride, + themed(textPresets["body"]), + ]; + + const $helperStyles = [ + $helperStyle, + status === "error" && { color: theme.colors.global.caution }, + HelperTextProps?.style, + ]; + + const $labelAndInputContainerStyles = [ + $globalStyles.flex1, + !(label || labelTx) && $globalStyles.justifyCenter, + ]; + + function focusInput() { + if (disabled) return; + input.current?.focus(); + } + + useImperativeHandle(ref, () => input.current as TextInput); + + return ( + + + {!!LeftAccessory && ( + + )} + + + {!!(label || labelTx) && ( + + )} + + + + + {!!RightAccessory && ( + + )} + + + {!!(helper || helperTx) && ( + + )} + + ); +}); + +const $containerStyle: ThemedStyle = ({ + spacing, + borderRadius, +}) => ({ + // ...debugBorder(), +}); + +const $labelStyle: ThemedStyle = ({ spacing }) => ({}); + +const $inputWrapperStyle: ThemedStyle = ({ + colors, + borderRadius, + borderWidth, + spacing, +}) => ({ + borderWidth: borderWidth.sm, + borderRadius: borderRadius.xs, + backgroundColor: colors.background.surface, + borderColor: colors.border.subtle, + overflow: "hidden", + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xxs, +}); + +const $inputStyle: ThemedStyle = ({ colors }) => ({}); + +const $helperStyle: ThemedStyle = ({ spacing }) => ({ + marginTop: spacing.xxxs, + paddingHorizontal: spacing.xs, +}); + +const $rightAccessoryStyle: ThemedStyle = ({ spacing }) => ({ + marginEnd: spacing.xxs, + flexShrink: 0, + justifyContent: "center", + alignItems: "center", +}); + +const $leftAccessoryStyle: ThemedStyle = ({ spacing }) => ({ + marginStart: spacing.xxs, + marginEnd: spacing.xxs, + flexShrink: 0, + justifyContent: "center", + alignItems: "center", +}); diff --git a/design-system/TextField/TextFieldSimple.tsx b/design-system/TextField/TextFieldSimple.tsx new file mode 100644 index 000000000..2a00d2a89 --- /dev/null +++ b/design-system/TextField/TextFieldSimple.tsx @@ -0,0 +1,38 @@ +import { memo } from "react"; + +import { TextField } from "./TextField"; +import { TextFieldProps } from "./TextField.props"; +import { useAppTheme } from "../../theme/useAppTheme"; + +type ITextFieldSimpleSize = "md" | "lg"; + +type ITextFieldSimpleProps = Omit< + TextFieldProps, + | "label" + | "labelTx" + | "labelTxOptions" + | "helper" + | "helperTx" + | "helperTxOptions" +> & { + size?: ITextFieldSimpleSize; +}; + +export const TextFieldSimple = memo(function TextFieldSimple( + props: ITextFieldSimpleProps +) { + const { theme } = useAppTheme(); + + const { size = "md", ...rest } = props; + + return ( + + ); +}); diff --git a/theme/border-radius.ts b/theme/border-radius.ts index 72b79955b..af42baa31 100644 --- a/theme/border-radius.ts +++ b/theme/border-radius.ts @@ -1,4 +1,5 @@ export const borderRadius = { + xs: 8, sm: 16, md: 24, } as const; diff --git a/theme/colorsDark.ts b/theme/colorsDark.ts index ab20a97ed..60d9210a1 100644 --- a/theme/colorsDark.ts +++ b/theme/colorsDark.ts @@ -31,7 +31,7 @@ export const colorsDark: IColors = { tertiary: lightPalette.light30, minimal: lightPalette.light8, accent: darkPalette.accent, - danger: darkPalette.danger, + caution: darkPalette.red, inverted: { primary: darkPalette.dark, secondary: darkPalette.dark60, @@ -59,7 +59,8 @@ export const colorsDark: IColors = { global: { primary: darkPalette.dark, primaryAlpha60: darkPalette.alpha60, - danger: darkPalette.danger, + caution: darkPalette.red, + transparent: darkPalette.transparent, inverted: { primary: lightPalette.light, primaryAlpha60: lightPalette.alpha60, diff --git a/theme/colorsLight.ts b/theme/colorsLight.ts index 58ec48463..fb271c5bc 100644 --- a/theme/colorsLight.ts +++ b/theme/colorsLight.ts @@ -30,7 +30,7 @@ export const colorsLight = { tertiary: darkPalette.dark30, minimal: darkPalette.dark4, accent: lightPalette.accent, - danger: lightPalette.danger, + caution: lightPalette.red, inverted: { primary: lightPalette.light, secondary: lightPalette.light60, @@ -58,7 +58,8 @@ export const colorsLight = { global: { primary: lightPalette.light, primaryAlpha60: lightPalette.alpha60, - danger: lightPalette.danger, + caution: lightPalette.red, + transparent: lightPalette.transparent, inverted: { primary: darkPalette.dark, primaryAlpha60: darkPalette.alpha60, diff --git a/theme/palette.ts b/theme/palette.ts index 36352c29f..2dcf29034 100644 --- a/theme/palette.ts +++ b/theme/palette.ts @@ -1,3 +1,9 @@ +const paletteShared = { + scrim: "rgba(0, 0, 0, 0.5)", + red: "#E30C00", + transparent: "transparent", +}; + export const lightPalette = { accent: "#7E00CC", @@ -18,7 +24,7 @@ export const lightPalette = { edge: "rgba(255, 255, 255, 0.15)", - danger: "#FF3B30", + ...paletteShared, }; export const darkPalette = { @@ -41,7 +47,7 @@ export const darkPalette = { edge: "rgba(0, 0, 0, 0.15)", - danger: "#FF3B30", + ...paletteShared, }; // Generated using https://callstack.github.io/react-native-paper/docs/guides/theming#theme-properties export const MaterialLightColors = { diff --git a/theme/styles.ts b/theme/styles.ts index fc0c45562..8c22555a7 100644 --- a/theme/styles.ts +++ b/theme/styles.ts @@ -5,6 +5,8 @@ export const $globalStyles = { row: { flexDirection: "row" } as ViewStyle, flex1: { flex: 1 } as ViewStyle, flexWrap: { flexWrap: "wrap" } as ViewStyle, + alignCenter: { alignItems: "center" } as ViewStyle, + justifyCenter: { justifyContent: "center" } as ViewStyle, center: { justifyContent: "center", alignItems: "center",