diff options
Diffstat (limited to 'src/components')
25 files changed, 2777 insertions, 0 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 000000000..d2100f0b4 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,507 @@ +import React from 'react' +import { + Pressable, + Text, + PressableProps, + TextProps, + ViewStyle, + AccessibilityProps, + View, + TextStyle, + StyleSheet, +} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' + +import {useTheme, atoms as a, tokens, web, native} from '#/alf' +import {Props as SVGIconProps} from '#/components/icons/common' + +export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' +export type ButtonColor = + | 'primary' + | 'secondary' + | 'negative' + | 'gradient_sky' + | 'gradient_midnight' + | 'gradient_sunrise' + | 'gradient_sunset' + | 'gradient_nordic' + | 'gradient_bonfire' +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 = React.PropsWithChildren< + Pick<PressableProps, 'disabled' | 'onPress'> & + AccessibilityProps & + VariantProps & { + label: string + } +> +export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} + +const Context = React.createContext< + VariantProps & { + hovered: boolean + focused: boolean + pressed: boolean + disabled: boolean + } +>({ + hovered: false, + focused: false, + pressed: false, + disabled: false, +}) + +export function useButtonContext() { + return React.useContext(Context) +} + +export function Button({ + children, + variant, + color, + size, + label, + 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, focusStyles} = 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(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: tokens.color.blue_500, + }) + hoverStyles.push(a.border, { + backgroundColor: light + ? t.palette.primary_50 + : t.palette.primary_950, + }) + } else { + baseStyles.push(a.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_100 + : tokens.color.gray_900, + }) + hoverStyles.push({ + backgroundColor: light + ? tokens.color.gray_200 + : tokens.color.gray_950, + }) + } else { + baseStyles.push({ + backgroundColor: light + ? tokens.color.gray_300 + : tokens.color.gray_950, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500, + }) + hoverStyles.push(a.border, t.atoms.bg_contrast_50) + } else { + baseStyles.push(a.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_900, + }) + } + } + } else if (color === 'negative') { + if (variant === 'solid') { + if (!disabled) { + baseStyles.push({ + backgroundColor: t.palette.negative_400, + }) + hoverStyles.push({ + backgroundColor: t.palette.negative_500, + }) + } else { + baseStyles.push({ + backgroundColor: t.palette.negative_600, + }) + } + } else if (variant === 'outline') { + baseStyles.push(a.border, t.atoms.bg, { + borderWidth: 1, + }) + + if (!disabled) { + baseStyles.push(a.border, { + borderColor: t.palette.negative_400, + }) + hoverStyles.push(a.border, { + backgroundColor: light + ? t.palette.negative_50 + : t.palette.negative_975, + }) + } else { + baseStyles.push(a.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_100 + : t.palette.negative_950, + }) + } + } + } + + if (size === 'large') { + baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm) + } else if (size === 'small') { + baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm) + } + + return { + baseStyles, + hoverStyles, + focusStyles: [ + ...hoverStyles, + { + outline: 0, + } as ViewStyle, + ], + } + }, [t, variant, color, size, disabled]) + + const {gradientColors, gradientHoverColors, gradientLocations} = + React.useMemo(() => { + const colors: string[] = [] + const hoverColors: string[] = [] + const locations: number[] = [] + const gradient = { + primary: tokens.gradients.sky, + secondary: tokens.gradients.sky, + negative: tokens.gradients.sky, + gradient_sky: tokens.gradients.sky, + gradient_midnight: tokens.gradients.midnight, + gradient_sunrise: tokens.gradients.sunrise, + gradient_sunset: tokens.gradients.sunset, + gradient_nordic: tokens.gradients.nordic, + gradient_bonfire: tokens.gradients.bonfire, + }[color || 'primary'] + + if (variant === 'gradient') { + colors.push(...gradient.values.map(([_, color]) => color)) + hoverColors.push(...gradient.values.map(_ => gradient.hover_value)) + locations.push(...gradient.values.map(([location, _]) => location)) + } + + return { + gradientColors: colors, + gradientHoverColors: hoverColors, + gradientLocations: locations, + } + }, [variant, color]) + + const context = React.useMemo( + () => ({ + ...state, + variant, + color, + size, + disabled: disabled || false, + }), + [state, variant, color, size, disabled], + ) + + return ( + <Pressable + role="button" + accessibilityHint={undefined} // optional + {...rest} + aria-label={label} + aria-pressed={state.pressed} + accessibilityLabel={label} + disabled={disabled || false} + accessibilityState={{ + disabled: disabled || false, + }} + style={[ + a.flex_row, + a.align_center, + a.overflow_hidden, + ...baseStyles, + ...(state.hovered || state.pressed ? hoverStyles : []), + ...(state.focused ? focusStyles : []), + ]} + onPressIn={onPressIn} + onPressOut={onPressOut} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} + onFocus={onFocus} + onBlur={onBlur}> + {variant === 'gradient' && ( + <LinearGradient + colors={ + state.hovered || state.pressed || state.focused + ? gradientHoverColors + : gradientColors + } + locations={gradientLocations} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[a.absolute, a.inset_0]} + /> + )} + <Context.Provider value={context}> + {typeof children === 'string' ? ( + <ButtonText>{children}</ButtonText> + ) : ( + children + )} + </Context.Provider> + </Pressable> + ) +} + +export function useSharedButtonTextStyles() { + const t = useTheme() + const {color, variant, disabled, size} = useButtonContext() + return React.useMemo(() => { + const baseStyles: TextStyle[] = [] + 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' || variant === 'gradient') { + 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' || variant === 'gradient') { + 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_400}) + } else { + baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) + } + } else if (variant === 'ghost') { + if (!disabled) { + baseStyles.push({color: t.palette.negative_400}) + } else { + baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) + } + } + } else { + if (!disabled) { + baseStyles.push({color: t.palette.white}) + } else { + baseStyles.push({color: t.palette.white, opacity: 0.5}) + } + } + + if (size === 'large') { + baseStyles.push( + a.text_md, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) + } else { + baseStyles.push( + a.text_md, + web({paddingBottom: 1}), + native({marginTop: 2}), + ) + } + + return StyleSheet.flatten(baseStyles) + }, [t, variant, color, size, disabled]) +} + +export function ButtonText({children, style, ...rest}: ButtonTextProps) { + const textStyles = useSharedButtonTextStyles() + + return ( + <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}> + {children} + </Text> + ) +} + +export function ButtonIcon({ + icon: Comp, +}: { + icon: React.ComponentType<SVGIconProps> +}) { + const {size} = useButtonContext() + const textStyles = useSharedButtonTextStyles() + + return ( + <View style={[a.z_20]}> + <Comp + size={size === 'large' ? 'md' : 'sm'} + style={[{color: textStyles.color, pointerEvents: 'none'}]} + /> + </View> + ) +} diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts new file mode 100644 index 000000000..b28b9f5a2 --- /dev/null +++ b/src/components/Dialog/context.ts @@ -0,0 +1,35 @@ +import React from 'react' + +import {useDialogStateContext} from '#/state/dialogs' +import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types' + +export const Context = React.createContext<DialogContextProps>({ + close: () => {}, +}) + +export function useDialogContext() { + return React.useContext(Context) +} + +export function useDialogControl() { + const id = React.useId() + const control = React.useRef<DialogControlProps>({ + open: () => {}, + close: () => {}, + }) + const {activeDialogs} = useDialogStateContext() + + React.useEffect(() => { + activeDialogs.current.set(id, control) + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + activeDialogs.current.delete(id) + } + }, [id, activeDialogs]) + + return { + ref: control, + open: () => control.current.open(), + close: () => control.current.close(), + } +} diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx new file mode 100644 index 000000000..44e4dc8a7 --- /dev/null +++ b/src/components/Dialog/index.tsx @@ -0,0 +1,162 @@ +import React, {useImperativeHandle} from 'react' +import {View, Dimensions} from 'react-native' +import BottomSheet, { + BottomSheetBackdrop, + BottomSheetScrollView, + BottomSheetTextInput, + BottomSheetView, +} from '@gorhom/bottom-sheet' +import {useSafeAreaInsets} from 'react-native-safe-area-context' + +import {useTheme, atoms as a} from '#/alf' +import {Portal} from '#/components/Portal' +import {createInput} from '#/components/forms/TextField' + +import { + DialogOuterProps, + DialogControlProps, + DialogInnerProps, +} from '#/components/Dialog/types' +import {Context} from '#/components/Dialog/context' + +export {useDialogControl, useDialogContext} from '#/components/Dialog/context' +export * from '#/components/Dialog/types' +// @ts-ignore +export const Input = createInput(BottomSheetTextInput) + +export function Outer({ + children, + control, + onClose, + nativeOptions, +}: React.PropsWithChildren<DialogOuterProps>) { + const t = useTheme() + const sheet = React.useRef<BottomSheet>(null) + const sheetOptions = nativeOptions?.sheet || {} + const hasSnapPoints = !!sheetOptions.snapPoints + + const open = React.useCallback<DialogControlProps['open']>((i = 0) => { + sheet.current?.snapToIndex(i) + }, []) + + const close = React.useCallback(() => { + sheet.current?.close() + onClose?.() + }, [onClose]) + + useImperativeHandle( + control.ref, + () => ({ + open, + close, + }), + [open, close], + ) + + const context = React.useMemo(() => ({close}), [close]) + + return ( + <Portal> + <BottomSheet + enableDynamicSizing={!hasSnapPoints} + enablePanDownToClose + keyboardBehavior="interactive" + android_keyboardInputMode="adjustResize" + keyboardBlurBehavior="restore" + {...sheetOptions} + ref={sheet} + index={-1} + backgroundStyle={{backgroundColor: 'transparent'}} + backdropComponent={props => ( + <BottomSheetBackdrop + opacity={0.4} + appearsOnIndex={0} + disappearsOnIndex={-1} + {...props} + /> + )} + handleIndicatorStyle={{backgroundColor: t.palette.primary_500}} + handleStyle={{display: 'none'}} + onClose={onClose}> + <Context.Provider value={context}> + <View + style={[ + a.absolute, + a.inset_0, + t.atoms.bg, + { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + height: Dimensions.get('window').height * 2, + }, + ]} + /> + {children} + </Context.Provider> + </BottomSheet> + </Portal> + ) +} + +// TODO a11y props here, or is that handled by the sheet? +export function Inner(props: DialogInnerProps) { + const insets = useSafeAreaInsets() + return ( + <BottomSheetView + style={[ + a.p_lg, + a.pt_3xl, + { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + paddingBottom: insets.bottom + a.pb_5xl.paddingBottom, + }, + ]}> + {props.children} + </BottomSheetView> + ) +} + +export function ScrollableInner(props: DialogInnerProps) { + const insets = useSafeAreaInsets() + return ( + <BottomSheetScrollView + style={[ + a.flex_1, // main diff is this + a.p_lg, + a.pt_3xl, + { + borderTopLeftRadius: 40, + borderTopRightRadius: 40, + }, + ]}> + {props.children} + <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> + </BottomSheetScrollView> + ) +} + +export function Handle() { + const t = useTheme() + return ( + <View + style={[ + a.absolute, + a.rounded_sm, + a.z_10, + { + top: a.pt_lg.paddingTop, + width: 35, + height: 4, + alignSelf: 'center', + backgroundColor: t.palette.contrast_900, + opacity: 0.5, + }, + ]} + /> + ) +} + +export function Close() { + return null +} diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx new file mode 100644 index 000000000..305c00e97 --- /dev/null +++ b/src/components/Dialog/index.web.tsx @@ -0,0 +1,194 @@ +import React, {useImperativeHandle} from 'react' +import {View, TouchableWithoutFeedback} from 'react-native' +import {FocusScope} from '@tamagui/focus-scope' +import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useTheme, atoms as a, useBreakpoints, web} from '#/alf' +import {Portal} from '#/components/Portal' + +import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types' +import {Context} from '#/components/Dialog/context' + +export {useDialogControl, useDialogContext} from '#/components/Dialog/context' +export * from '#/components/Dialog/types' +export {Input} from '#/components/forms/TextField' + +const stopPropagation = (e: any) => e.stopPropagation() + +export function Outer({ + control, + onClose, + children, +}: React.PropsWithChildren<DialogOuterProps>) { + const {_} = useLingui() + const t = useTheme() + const {gtMobile} = useBreakpoints() + const [isOpen, setIsOpen] = React.useState(false) + const [isVisible, setIsVisible] = React.useState(true) + + const open = React.useCallback(() => { + setIsOpen(true) + }, [setIsOpen]) + + const close = React.useCallback(async () => { + setIsVisible(false) + await new Promise(resolve => setTimeout(resolve, 150)) + setIsOpen(false) + setIsVisible(true) + onClose?.() + }, [onClose, setIsOpen]) + + useImperativeHandle( + control.ref, + () => ({ + open, + close, + }), + [open, close], + ) + + React.useEffect(() => { + if (!isOpen) return + + function handler(e: KeyboardEvent) { + if (e.key === 'Escape') close() + } + + document.addEventListener('keydown', handler) + + return () => document.removeEventListener('keydown', handler) + }, [isOpen, close]) + + const context = React.useMemo( + () => ({ + close, + }), + [close], + ) + + return ( + <> + {isOpen && ( + <Portal> + <Context.Provider value={context}> + <TouchableWithoutFeedback + accessibilityHint={undefined} + accessibilityLabel={_(msg`Close active dialog`)} + onPress={close}> + <View + style={[ + web(a.fixed), + a.inset_0, + a.z_10, + a.align_center, + gtMobile ? a.p_lg : a.p_md, + {overflowY: 'auto'}, + ]}> + {isVisible && ( + <Animated.View + entering={FadeIn.duration(150)} + // exiting={FadeOut.duration(150)} + style={[ + web(a.fixed), + a.inset_0, + {opacity: 0.5, backgroundColor: t.palette.black}, + ]} + /> + )} + + <View + style={[ + a.w_full, + a.z_20, + a.justify_center, + a.align_center, + { + minHeight: web('calc(90vh - 36px)') || undefined, + }, + ]}> + {isVisible ? children : null} + </View> + </View> + </TouchableWithoutFeedback> + </Context.Provider> + </Portal> + )} + </> + ) +} + +export function Inner({ + children, + style, + label, + accessibilityLabelledBy, + accessibilityDescribedBy, +}: DialogInnerProps) { + const t = useTheme() + const {gtMobile} = useBreakpoints() + return ( + <FocusScope loop enabled trapped> + <Animated.View + role="dialog" + aria-role="dialog" + aria-label={label} + aria-labelledby={accessibilityLabelledBy} + aria-describedby={accessibilityDescribedBy} + // @ts-ignore web only -prf + onClick={stopPropagation} + onStartShouldSetResponder={_ => true} + onTouchEnd={stopPropagation} + entering={FadeInDown.duration(100)} + // exiting={FadeOut.duration(100)} + style={[ + a.relative, + a.rounded_md, + a.w_full, + a.border, + gtMobile ? a.p_xl : a.p_lg, + t.atoms.bg, + { + maxWidth: 600, + borderColor: t.palette.contrast_200, + shadowColor: t.palette.black, + shadowOpacity: t.name === 'light' ? 0.1 : 0.4, + shadowRadius: 30, + }, + ...(Array.isArray(style) ? style : [style || {}]), + ]}> + {children} + </Animated.View> + </FocusScope> + ) +} + +export const ScrollableInner = Inner + +export function Handle() { + return null +} + +/** + * TODO(eric) unused rn + */ +// export function Close() { +// const {_} = useLingui() +// const t = useTheme() +// const {close} = useDialogContext() +// return ( +// <View +// style={[ +// a.absolute, +// a.z_10, +// { +// top: a.pt_lg.paddingTop, +// right: a.pr_lg.paddingRight, +// }, +// ]}> +// <Button onPress={close} label={_(msg`Close active dialog`)}> +// </Button> +// </View> +// ) +// } diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts new file mode 100644 index 000000000..d36784183 --- /dev/null +++ b/src/components/Dialog/types.ts @@ -0,0 +1,43 @@ +import React from 'react' +import type {ViewStyle, AccessibilityProps} from 'react-native' +import {BottomSheetProps} from '@gorhom/bottom-sheet' + +type A11yProps = Required<AccessibilityProps> + +export type DialogContextProps = { + close: () => void +} + +export type DialogControlProps = { + open: (index?: number) => void + close: () => void +} + +export type DialogOuterProps = { + control: { + ref: React.RefObject<DialogControlProps> + open: (index?: number) => void + close: () => void + } + onClose?: () => void + nativeOptions?: { + sheet?: Omit<BottomSheetProps, 'children'> + } + webOptions?: {} +} + +type DialogInnerPropsBase<T> = React.PropsWithChildren<{ + style?: ViewStyle +}> & + T +export type DialogInnerProps = + | DialogInnerPropsBase<{ + label?: undefined + accessibilityLabelledBy: A11yProps['aria-labelledby'] + accessibilityDescribedBy: string + }> + | DialogInnerPropsBase<{ + label: string + accessibilityLabelledBy?: undefined + accessibilityDescribedBy?: undefined + }> diff --git a/src/components/Link.tsx b/src/components/Link.tsx new file mode 100644 index 000000000..8f686f3c4 --- /dev/null +++ b/src/components/Link.tsx @@ -0,0 +1,191 @@ +import React from 'react' +import { + Text, + TextStyle, + StyleProp, + GestureResponderEvent, + Linking, +} from 'react-native' +import { + useLinkProps, + useNavigation, + StackActions, +} from '@react-navigation/native' +import {sanitizeUrl} from '@braintree/sanitize-url' + +import {isWeb} from '#/platform/detection' +import {useTheme, web, flatten} from '#/alf' +import {Button, ButtonProps, useButtonContext} from '#/components/Button' +import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' +import { + convertBskyAppUrlIfNeeded, + isExternalUrl, + linkRequiresWarning, +} from '#/lib/strings/url-helpers' +import {useModalControls} from '#/state/modals' +import {router} from '#/routes' + +export type LinkProps = Omit< + ButtonProps, + 'style' | 'onPress' | 'disabled' | 'label' +> & { + /** + * `TextStyle` to apply to the anchor element itself. Does not apply to any children. + */ + style?: StyleProp<TextStyle> + /** + * The React Navigation `StackAction` to perform when the link is pressed. + */ + action?: 'push' | 'replace' | 'navigate' + /** + * If true, will warn the user if the link text does not match the href. Only + * works for Links with children that are strings i.e. text links. + */ + warnOnMismatchingTextChild?: boolean + label?: ButtonProps['label'] +} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'> + +/** + * A interactive element that renders as a `<a>` tag on the web. On mobile it + * will translate the `href` to navigator screens and params and dispatch a + * navigation action. + * + * Intended to behave as a web anchor tag. For more complex routing, use a + * `Button`. + */ +export function Link({ + children, + to, + action = 'push', + warnOnMismatchingTextChild, + style, + ...rest +}: LinkProps) { + const navigation = useNavigation<NavigationProp>() + const {href} = useLinkProps<AllNavigatorParams>({ + to: + typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to, + }) + const isExternal = isExternalUrl(href) + const {openModal, closeModal} = useModalControls() + const onPress = React.useCallback( + (e: GestureResponderEvent) => { + const stringChildren = typeof children === 'string' ? children : '' + const requiresWarning = Boolean( + warnOnMismatchingTextChild && + stringChildren && + isExternal && + linkRequiresWarning(href, stringChildren), + ) + + if (requiresWarning) { + e.preventDefault() + + openModal({ + name: 'link-warning', + text: stringChildren, + href: href, + }) + } else { + e.preventDefault() + + if (isExternal) { + Linking.openURL(href) + } else { + /** + * A `GestureResponderEvent`, but cast to `any` to avoid using a bunch + * of @ts-ignore below. + */ + const event = e as any + const isMiddleClick = isWeb && event.button === 1 + const isMetaKey = + isWeb && + (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) + const shouldOpenInNewTab = isMetaKey || isMiddleClick + + if ( + shouldOpenInNewTab || + href.startsWith('http') || + href.startsWith('mailto') + ) { + Linking.openURL(href) + } else { + closeModal() // close any active modals + + if (action === 'push') { + navigation.dispatch(StackActions.push(...router.matchPath(href))) + } else if (action === 'replace') { + navigation.dispatch( + StackActions.replace(...router.matchPath(href)), + ) + } else if (action === 'navigate') { + // @ts-ignore + navigation.navigate(...router.matchPath(href)) + } else { + throw Error('Unsupported navigator action.') + } + } + } + } + }, + [ + href, + isExternal, + warnOnMismatchingTextChild, + navigation, + action, + children, + closeModal, + openModal, + ], + ) + + return ( + <Button + label={href} + {...rest} + role="link" + accessibilityRole="link" + href={href} + onPress={onPress} + {...web({ + hrefAttrs: { + target: isExternal ? 'blank' : undefined, + rel: isExternal ? 'noopener noreferrer' : undefined, + }, + dataSet: { + // default to no underline, apply this ourselves + noUnderline: '1', + }, + })}> + {typeof children === 'string' ? ( + <LinkText style={style}>{children}</LinkText> + ) : ( + children + )} + </Button> + ) +} + +function LinkText({ + children, + style, +}: React.PropsWithChildren<{ + style?: StyleProp<TextStyle> +}>) { + const t = useTheme() + const {hovered} = useButtonContext() + return ( + <Text + style={[ + {color: t.palette.primary_500}, + hovered && { + textDecorationLine: 'underline', + textDecorationColor: t.palette.primary_500, + }, + flatten(style), + ]}> + {children as string} + </Text> + ) +} diff --git a/src/components/Portal.tsx b/src/components/Portal.tsx new file mode 100644 index 000000000..1813d9e05 --- /dev/null +++ b/src/components/Portal.tsx @@ -0,0 +1,56 @@ +import React from 'react' + +type Component = React.ReactElement + +type ContextType = { + outlet: Component | null + append(id: string, component: Component): void + remove(id: string): void +} + +type ComponentMap = { + [id: string]: Component +} + +export const Context = React.createContext<ContextType>({ + outlet: null, + append: () => {}, + remove: () => {}, +}) + +export function Provider(props: React.PropsWithChildren<{}>) { + const map = React.useRef<ComponentMap>({}) + const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null) + + const append = React.useCallback<ContextType['append']>((id, component) => { + if (map.current[id]) return + map.current[id] = <React.Fragment key={id}>{component}</React.Fragment> + setOutlet(<>{Object.values(map.current)}</>) + }, []) + + const remove = React.useCallback<ContextType['remove']>(id => { + delete map.current[id] + setOutlet(<>{Object.values(map.current)}</>) + }, []) + + return ( + <Context.Provider value={{outlet, append, remove}}> + {props.children} + </Context.Provider> + ) +} + +export function Outlet() { + const ctx = React.useContext(Context) + return ctx.outlet +} + +export function Portal({children}: React.PropsWithChildren<{}>) { + const {append, remove} = React.useContext(Context) + const id = React.useId() + React.useEffect(() => { + append(id, children as Component) + return () => remove(id) + }, [id, children, append, remove]) + return null +} diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx new file mode 100644 index 000000000..7115f6190 --- /dev/null +++ b/src/components/Prompt.tsx @@ -0,0 +1,119 @@ +import React from 'react' +import {View, PressableProps} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useTheme, atoms as a} from '#/alf' +import {H4, P} from '#/components/Typography' +import {Button} from '#/components/Button' + +import * as Dialog from '#/components/Dialog' + +export {useDialogControl as usePromptControl} from '#/components/Dialog' + +const Context = React.createContext<{ + titleId: string + descriptionId: string +}>({ + titleId: '', + descriptionId: '', +}) + +export function Outer({ + children, + control, +}: React.PropsWithChildren<{ + control: Dialog.DialogOuterProps['control'] +}>) { + const titleId = React.useId() + const descriptionId = React.useId() + + const context = React.useMemo( + () => ({titleId, descriptionId}), + [titleId, descriptionId], + ) + + return ( + <Dialog.Outer control={control}> + <Context.Provider value={context}> + <Dialog.Handle /> + + <Dialog.Inner + accessibilityLabelledBy={titleId} + accessibilityDescribedBy={descriptionId} + style={{width: 'auto', maxWidth: 400}}> + {children} + </Dialog.Inner> + </Context.Provider> + </Dialog.Outer> + ) +} + +export function Title({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const {titleId} = React.useContext(Context) + return ( + <H4 + nativeID={titleId} + style={[a.font_bold, t.atoms.text_contrast_700, a.pb_sm]}> + {children} + </H4> + ) +} + +export function Description({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const {descriptionId} = React.useContext(Context) + return ( + <P nativeID={descriptionId} style={[t.atoms.text, a.pb_lg]}> + {children} + </P> + ) +} + +export function Actions({children}: React.PropsWithChildren<{}>) { + return ( + <View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}> + {children} + </View> + ) +} + +export function Cancel({ + children, +}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) { + const {_} = useLingui() + const {close} = Dialog.useDialogContext() + return ( + <Button + variant="solid" + color="secondary" + size="small" + label={_(msg`Cancel`)} + onPress={close}> + {children} + </Button> + ) +} + +export function Action({ + children, + onPress, +}: React.PropsWithChildren<{onPress?: () => void}>) { + const {_} = useLingui() + const {close} = Dialog.useDialogContext() + const handleOnPress = React.useCallback(() => { + close() + onPress?.() + }, [close, onPress]) + return ( + <Button + variant="solid" + color="primary" + size="small" + label={_(msg`Confirm`)} + onPress={handleOnPress}> + {children} + </Button> + ) +} diff --git a/src/components/Typography.tsx b/src/components/Typography.tsx new file mode 100644 index 000000000..66cf0720d --- /dev/null +++ b/src/components/Typography.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import {Text as RNText, TextProps} from 'react-native' + +import {useTheme, atoms, web, flatten} from '#/alf' + +export function Text({style, ...rest}: TextProps) { + const t = useTheme() + return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} /> +} + +export function H1({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 1, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]} + /> + ) +} + +export function H2({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 2, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]} + /> + ) +} + +export function H3({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 3, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]} + /> + ) +} + +export function H4({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 4, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]} + /> + ) +} + +export function H5({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 5, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]} + /> + ) +} + +export function H6({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'heading', + 'aria-level': 6, + }) || {} + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]} + /> + ) +} + +export function P({style, ...rest}: TextProps) { + const t = useTheme() + const attr = + web({ + role: 'paragraph', + }) || {} + const _style = flatten(style) + const lineHeight = + (_style?.lineHeight || atoms.text_md.lineHeight) * + atoms.leading_normal.lineHeight + return ( + <RNText + {...attr} + {...rest} + style={[atoms.text_md, t.atoms.text, _style, {lineHeight}]} + /> + ) +} diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx new file mode 100644 index 000000000..83fa285f5 --- /dev/null +++ b/src/components/forms/DateField/index.android.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import {View, Pressable} from 'react-native' +import DateTimePicker, { + BaseProps as DateTimePickerProps, +} from '@react-native-community/datetimepicker' + +import {useTheme, atoms} from '#/alf' +import {Text} from '#/components/Typography' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import * as TextField from '#/components/forms/TextField' +import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' + +import {DateFieldProps} from '#/components/forms/DateField/types' +import { + localizeDate, + toSimpleDateString, +} from '#/components/forms/DateField/utils' + +export * as utils from '#/components/forms/DateField/utils' +export const Label = TextField.Label + +export function DateField({ + value, + onChangeDate, + label, + isInvalid, + testID, +}: DateFieldProps) { + const t = useTheme() + const [open, setOpen] = React.useState(false) + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const {chromeFocus, chromeError, chromeErrorHover} = + TextField.useSharedInputStyles() + + const onChangeInternal = React.useCallback< + Required<DateTimePickerProps>['onChange'] + >( + (_event, date) => { + setOpen(false) + + if (date) { + const formatted = toSimpleDateString(date) + onChangeDate(formatted) + } + }, + [onChangeDate, setOpen], + ) + + return ( + <View style={[atoms.relative, atoms.w_full]}> + <Pressable + aria-label={label} + accessibilityLabel={label} + accessibilityHint={undefined} + onPress={() => setOpen(true)} + onPressIn={onPressIn} + onPressOut={onPressOut} + onFocus={onFocus} + onBlur={onBlur} + style={[ + { + paddingTop: 16, + paddingBottom: 16, + borderColor: 'transparent', + borderWidth: 2, + }, + atoms.flex_row, + atoms.flex_1, + atoms.w_full, + atoms.px_lg, + atoms.rounded_sm, + t.atoms.bg_contrast_50, + focused || pressed ? chromeFocus : {}, + isInvalid ? chromeError : {}, + isInvalid && (focused || pressed) ? chromeErrorHover : {}, + ]}> + <TextField.Icon icon={CalendarDays} /> + + <Text + style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}> + {localizeDate(value)} + </Text> + </Pressable> + + {open && ( + <DateTimePicker + aria-label={label} + accessibilityLabel={label} + accessibilityHint={undefined} + testID={`${testID}-datepicker`} + mode="date" + timeZoneName={'Etc/UTC'} + display="spinner" + // @ts-ignore applies in iOS only -prf + themeVariant={t.name === 'dark' ? 'dark' : 'light'} + value={new Date(value)} + onChange={onChangeInternal} + /> + )} + </View> + ) +} diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx new file mode 100644 index 000000000..c359a9d46 --- /dev/null +++ b/src/components/forms/DateField/index.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import {View} from 'react-native' +import DateTimePicker, { + DateTimePickerEvent, +} from '@react-native-community/datetimepicker' + +import {useTheme, atoms} from '#/alf' +import * as TextField from '#/components/forms/TextField' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import {DateFieldProps} from '#/components/forms/DateField/types' + +export * as utils from '#/components/forms/DateField/utils' +export const Label = TextField.Label + +/** + * Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date + * changes in the same format. + * + * For dates of unknown format, convert with the + * `utils.toSimpleDateString(Date)` export of this file. + */ +export function DateField({ + value, + onChangeDate, + testID, + label, +}: DateFieldProps) { + const t = useTheme() + + const onChangeInternal = React.useCallback( + (event: DateTimePickerEvent, date: Date | undefined) => { + if (date) { + const formatted = toSimpleDateString(date) + onChangeDate(formatted) + } + }, + [onChangeDate], + ) + + return ( + <View style={[atoms.relative, atoms.w_full]}> + <DateTimePicker + aria-label={label} + accessibilityLabel={label} + accessibilityHint={undefined} + testID={`${testID}-datepicker`} + mode="date" + timeZoneName={'Etc/UTC'} + display="spinner" + themeVariant={t.name === 'dark' ? 'dark' : 'light'} + value={new Date(value)} + onChange={onChangeInternal} + /> + </View> + ) +} diff --git a/src/components/forms/DateField/index.web.tsx b/src/components/forms/DateField/index.web.tsx new file mode 100644 index 000000000..32f38a5d1 --- /dev/null +++ b/src/components/forms/DateField/index.web.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import {TextInput, TextInputProps, StyleSheet} from 'react-native' +// @ts-ignore +import {unstable_createElement} from 'react-native-web' + +import * as TextField from '#/components/forms/TextField' +import {toSimpleDateString} from '#/components/forms/DateField/utils' +import {DateFieldProps} from '#/components/forms/DateField/types' + +export * as utils from '#/components/forms/DateField/utils' +export const Label = TextField.Label + +const InputBase = React.forwardRef<HTMLInputElement, TextInputProps>( + ({style, ...props}, ref) => { + return unstable_createElement('input', { + ...props, + ref, + type: 'date', + style: [ + StyleSheet.flatten(style), + { + background: 'transparent', + border: 0, + }, + ], + }) + }, +) + +InputBase.displayName = 'InputBase' + +const Input = TextField.createInput(InputBase as unknown as typeof TextInput) + +export function DateField({ + value, + onChangeDate, + label, + isInvalid, + testID, +}: DateFieldProps) { + const handleOnChange = React.useCallback( + (e: any) => { + const date = e.target.valueAsDate || e.target.value + + if (date) { + const formatted = toSimpleDateString(date) + onChangeDate(formatted) + } + }, + [onChangeDate], + ) + + return ( + <TextField.Root isInvalid={isInvalid}> + <Input + value={value} + label={label} + onChange={handleOnChange} + onChangeText={() => {}} + testID={testID} + /> + </TextField.Root> + ) +} diff --git a/src/components/forms/DateField/types.ts b/src/components/forms/DateField/types.ts new file mode 100644 index 000000000..129f5672d --- /dev/null +++ b/src/components/forms/DateField/types.ts @@ -0,0 +1,7 @@ +export type DateFieldProps = { + value: string + onChangeDate: (date: string) => void + label: string + isInvalid?: boolean + testID?: string +} diff --git a/src/components/forms/DateField/utils.ts b/src/components/forms/DateField/utils.ts new file mode 100644 index 000000000..c787272fe --- /dev/null +++ b/src/components/forms/DateField/utils.ts @@ -0,0 +1,16 @@ +import {getLocales} from 'expo-localization' + +const LOCALE = getLocales()[0] + +// we need the date in the form yyyy-MM-dd to pass to the input +export function toSimpleDateString(date: Date | string): string { + const _date = typeof date === 'string' ? new Date(date) : date + return _date.toISOString().split('T')[0] +} + +export function localizeDate(date: Date | string): string { + const _date = typeof date === 'string' ? new Date(date) : date + return new Intl.DateTimeFormat(LOCALE.languageTag, { + timeZone: 'UTC', + }).format(_date) +} diff --git a/src/components/forms/InputGroup.tsx b/src/components/forms/InputGroup.tsx new file mode 100644 index 000000000..6908d4df8 --- /dev/null +++ b/src/components/forms/InputGroup.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms, useTheme} from '#/alf' + +/** + * NOT FINISHED, just here as a reference + */ +export function InputGroup(props: React.PropsWithChildren<{}>) { + const t = useTheme() + const children = React.Children.toArray(props.children) + const total = children.length + return ( + <View style={[atoms.w_full]}> + {children.map((child, i) => { + return React.isValidElement(child) ? ( + <React.Fragment key={i}> + {i > 0 ? ( + <View + style={[atoms.border_b, {borderColor: t.palette.contrast_500}]} + /> + ) : null} + {React.cloneElement(child, { + // @ts-ignore + style: [ + ...(Array.isArray(child.props?.style) + ? child.props.style + : [child.props.style || {}]), + { + borderTopLeftRadius: i > 0 ? 0 : undefined, + borderTopRightRadius: i > 0 ? 0 : undefined, + borderBottomLeftRadius: i < total - 1 ? 0 : undefined, + borderBottomRightRadius: i < total - 1 ? 0 : undefined, + borderBottomWidth: i < total - 1 ? 0 : undefined, + }, + ], + })} + </React.Fragment> + ) : null + })} + </View> + ) +} diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx new file mode 100644 index 000000000..1ee58303a --- /dev/null +++ b/src/components/forms/TextField.tsx @@ -0,0 +1,334 @@ +import React from 'react' +import { + View, + TextInput, + TextInputProps, + TextStyle, + ViewStyle, + Pressable, + StyleSheet, + AccessibilityProps, +} from 'react-native' + +import {HITSLOP_20} from 'lib/constants' +import {isWeb} from '#/platform/detection' +import {useTheme, atoms as a, web, tokens, android} from '#/alf' +import {Text} from '#/components/Typography' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Props as SVGIconProps} from '#/components/icons/common' + +const Context = React.createContext<{ + inputRef: React.RefObject<TextInput> | null + isInvalid: boolean + hovered: boolean + onHoverIn: () => void + onHoverOut: () => void + focused: boolean + onFocus: () => void + onBlur: () => void +}>({ + inputRef: null, + isInvalid: false, + hovered: false, + onHoverIn: () => {}, + onHoverOut: () => {}, + focused: false, + onFocus: () => {}, + onBlur: () => {}, +}) + +export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}> + +export function Root({children, isInvalid = false}: RootProps) { + const inputRef = React.useRef<TextInput>(null) + const rootRef = React.useRef<View>(null) + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const context = React.useMemo( + () => ({ + inputRef, + hovered, + onHoverIn, + onHoverOut, + focused, + onFocus, + onBlur, + isInvalid, + }), + [ + inputRef, + hovered, + onHoverIn, + onHoverOut, + focused, + onFocus, + onBlur, + isInvalid, + ], + ) + + React.useLayoutEffect(() => { + const root = rootRef.current + if (!root || !isWeb) return + // @ts-ignore web only + root.tabIndex = -1 + }, []) + + return ( + <Context.Provider value={context}> + <Pressable + accessibilityRole="button" + ref={rootRef} + role="none" + style={[ + a.flex_row, + a.align_center, + a.relative, + a.w_full, + a.px_md, + { + paddingVertical: 14, + }, + ]} + // onPressIn/out don't work on android web + onPress={() => inputRef.current?.focus()} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut}> + {children} + </Pressable> + </Context.Provider> + ) +} + +export function useSharedInputStyles() { + const t = useTheme() + return React.useMemo(() => { + const hover: ViewStyle[] = [ + { + borderColor: t.palette.contrast_100, + }, + ] + const focus: ViewStyle[] = [ + { + backgroundColor: t.palette.contrast_50, + borderColor: t.palette.primary_500, + }, + ] + const error: ViewStyle[] = [ + { + backgroundColor: + t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, + borderColor: + t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800, + }, + ] + const errorHover: ViewStyle[] = [ + { + backgroundColor: + t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, + borderColor: tokens.color.red_500, + }, + ] + + return { + chromeHover: StyleSheet.flatten(hover), + chromeFocus: StyleSheet.flatten(focus), + chromeError: StyleSheet.flatten(error), + chromeErrorHover: StyleSheet.flatten(errorHover), + } + }, [t]) +} + +export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & { + label: string + value: string + onChangeText: (value: string) => void + isInvalid?: boolean +} + +export function createInput(Component: typeof TextInput) { + return function Input({ + label, + placeholder, + value, + onChangeText, + isInvalid, + ...rest + }: InputProps) { + const t = useTheme() + const ctx = React.useContext(Context) + const withinRoot = Boolean(ctx.inputRef) + + const {chromeHover, chromeFocus, chromeError, chromeErrorHover} = + useSharedInputStyles() + + if (!withinRoot) { + return ( + <Root isInvalid={isInvalid}> + <Input + label={label} + placeholder={placeholder} + value={value} + onChangeText={onChangeText} + isInvalid={isInvalid} + {...rest} + /> + </Root> + ) + } + + return ( + <> + <Component + accessibilityHint={undefined} + {...rest} + aria-label={label} + accessibilityLabel={label} + ref={ctx.inputRef} + value={value} + onChangeText={onChangeText} + onFocus={ctx.onFocus} + onBlur={ctx.onBlur} + placeholder={placeholder || label} + placeholderTextColor={t.palette.contrast_500} + hitSlop={HITSLOP_20} + style={[ + a.relative, + a.z_20, + a.flex_1, + a.text_md, + t.atoms.text, + a.px_xs, + android({ + paddingBottom: 2, + }), + { + lineHeight: a.text_md.lineHeight * 1.1875, + textAlignVertical: rest.multiline ? 'top' : undefined, + minHeight: rest.multiline ? 60 : undefined, + }, + ]} + /> + + <View + style={[ + a.z_10, + a.absolute, + a.inset_0, + a.rounded_sm, + t.atoms.bg_contrast_25, + {borderColor: 'transparent', borderWidth: 2}, + ctx.hovered ? chromeHover : {}, + ctx.focused ? chromeFocus : {}, + ctx.isInvalid || isInvalid ? chromeError : {}, + (ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused) + ? chromeErrorHover + : {}, + ]} + /> + </> + ) + } +} + +export const Input = createInput(TextInput) + +export function Label({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + return ( + <Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_600, a.mb_sm]}> + {children} + </Text> + ) +} + +export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) { + const t = useTheme() + const ctx = React.useContext(Context) + const {hover, focus, errorHover, errorFocus} = React.useMemo(() => { + const hover: TextStyle[] = [ + { + color: t.palette.contrast_800, + }, + ] + const focus: TextStyle[] = [ + { + color: t.palette.primary_500, + }, + ] + const errorHover: TextStyle[] = [ + { + color: t.palette.negative_500, + }, + ] + const errorFocus: TextStyle[] = [ + { + color: t.palette.negative_500, + }, + ] + + return { + hover, + focus, + errorHover, + errorFocus, + } + }, [t]) + + return ( + <View style={[a.z_20, a.pr_xs]}> + <Comp + size="md" + style={[ + {color: t.palette.contrast_500, pointerEvents: 'none'}, + ctx.hovered ? hover : {}, + ctx.focused ? focus : {}, + ctx.isInvalid && ctx.hovered ? errorHover : {}, + ctx.isInvalid && ctx.focused ? errorFocus : {}, + ]} + /> + </View> + ) +} + +export function Suffix({ + children, + label, + accessibilityHint, +}: React.PropsWithChildren<{ + label: string + accessibilityHint?: AccessibilityProps['accessibilityHint'] +}>) { + const t = useTheme() + const ctx = React.useContext(Context) + return ( + <Text + aria-label={label} + accessibilityLabel={label} + accessibilityHint={accessibilityHint} + style={[ + a.z_20, + a.pr_sm, + a.text_md, + t.atoms.text_contrast_400, + { + pointerEvents: 'none', + }, + web({ + marginTop: -2, + }), + ctx.hovered || ctx.focused + ? { + color: t.palette.contrast_800, + } + : {}, + ]}> + {children} + </Text> + ) +} diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx new file mode 100644 index 000000000..ad82bdff5 --- /dev/null +++ b/src/components/forms/Toggle.tsx @@ -0,0 +1,473 @@ +import React from 'react' +import {Pressable, View, ViewStyle} from 'react-native' + +import {HITSLOP_10} from 'lib/constants' +import {useTheme, atoms as a, web, native} from '#/alf' +import {Text} from '#/components/Typography' +import {useInteractionState} from '#/components/hooks/useInteractionState' + +export type ItemState = { + name: string + selected: boolean + disabled: boolean + isInvalid: boolean + hovered: boolean + pressed: boolean + focused: boolean +} + +const ItemContext = React.createContext<ItemState>({ + name: '', + selected: false, + disabled: false, + isInvalid: false, + hovered: false, + pressed: false, + focused: false, +}) + +const GroupContext = React.createContext<{ + values: string[] + disabled: boolean + type: 'radio' | 'checkbox' + maxSelectionsReached: boolean + setFieldValue: (props: {name: string; value: boolean}) => void +}>({ + type: 'checkbox', + values: [], + disabled: false, + maxSelectionsReached: false, + setFieldValue: () => {}, +}) + +export type GroupProps = React.PropsWithChildren<{ + type?: 'radio' | 'checkbox' + values: string[] + maxSelections?: number + disabled?: boolean + onChange: (value: string[]) => void + label: string +}> + +export type ItemProps = { + type?: 'radio' | 'checkbox' + name: string + label: string + value?: boolean + disabled?: boolean + onChange?: (selected: boolean) => void + isInvalid?: boolean + style?: (state: ItemState) => ViewStyle + children: ((props: ItemState) => React.ReactNode) | React.ReactNode +} + +export function useItemContext() { + return React.useContext(ItemContext) +} + +export function Group({ + children, + values: providedValues, + onChange, + disabled = false, + type = 'checkbox', + maxSelections, + label, +}: GroupProps) { + const groupRole = type === 'radio' ? 'radiogroup' : undefined + const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues + const [maxReached, setMaxReached] = React.useState(false) + + const setFieldValue = React.useCallback< + (props: {name: string; value: boolean}) => void + >( + ({name, value}) => { + if (type === 'checkbox') { + const pruned = values.filter(v => v !== name) + const next = value ? pruned.concat(name) : pruned + onChange(next) + } else { + onChange([name]) + } + }, + [type, onChange, values], + ) + + React.useEffect(() => { + if (type === 'checkbox') { + if ( + maxSelections && + values.length >= maxSelections && + maxReached === false + ) { + setMaxReached(true) + } else if ( + maxSelections && + values.length < maxSelections && + maxReached === true + ) { + setMaxReached(false) + } + } + }, [type, values.length, maxSelections, maxReached, setMaxReached]) + + const context = React.useMemo( + () => ({ + values, + type, + disabled, + maxSelectionsReached: maxReached, + setFieldValue, + }), + [values, disabled, type, maxReached, setFieldValue], + ) + + return ( + <GroupContext.Provider value={context}> + <View + role={groupRole} + {...(groupRole === 'radiogroup' + ? { + 'aria-label': label, + accessibilityLabel: label, + accessibilityRole: groupRole, + } + : {})}> + {children} + </View> + </GroupContext.Provider> + ) +} + +export function Item({ + children, + name, + value = false, + disabled: itemDisabled = false, + onChange, + isInvalid, + style, + type = 'checkbox', + label, + ...rest +}: ItemProps) { + const { + values: selectedValues, + type: groupType, + disabled: groupDisabled, + setFieldValue, + maxSelectionsReached, + } = React.useContext(GroupContext) + const { + state: hovered, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + const role = groupType === 'radio' ? 'radio' : type + const selected = selectedValues.includes(name) || !!value + const disabled = + groupDisabled || itemDisabled || (!selected && maxSelectionsReached) + + const onPress = React.useCallback(() => { + const next = !selected + setFieldValue({name, value: next}) + onChange?.(next) + }, [name, selected, onChange, setFieldValue]) + + const state = React.useMemo( + () => ({ + name, + selected, + disabled: disabled ?? false, + isInvalid: isInvalid ?? false, + hovered, + pressed, + focused, + }), + [name, selected, disabled, hovered, pressed, focused, isInvalid], + ) + + return ( + <ItemContext.Provider value={state}> + <Pressable + accessibilityHint={undefined} // optional + hitSlop={HITSLOP_10} + {...rest} + disabled={disabled} + aria-disabled={disabled ?? false} + aria-checked={selected} + aria-invalid={isInvalid} + aria-label={label} + role={role} + accessibilityRole={role} + accessibilityState={{ + disabled: disabled ?? false, + selected: selected, + }} + accessibilityLabel={label} + onPress={onPress} + onHoverIn={onHoverIn} + onHoverOut={onHoverOut} + onPressIn={onPressIn} + onPressOut={onPressOut} + onFocus={onFocus} + onBlur={onBlur} + style={[ + a.flex_row, + a.align_center, + a.gap_sm, + focused ? web({outline: 'none'}) : {}, + style?.(state), + ]}> + {typeof children === 'function' ? children(state) : children} + </Pressable> + </ItemContext.Provider> + ) +} + +export function Label({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const {disabled} = useItemContext() + return ( + <Text + style={[ + a.font_bold, + { + userSelect: 'none', + color: disabled ? t.palette.contrast_400 : t.palette.contrast_600, + }, + native({ + paddingTop: 3, + }), + ]}> + {children} + </Text> + ) +} + +// TODO(eric) refactor to memoize styles without knowledge of state +export function createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, +}: { + theme: ReturnType<typeof useTheme> + selected: boolean + hovered: boolean + focused: boolean + disabled: boolean + isInvalid: boolean +}) { + const base: ViewStyle[] = [] + const baseHover: ViewStyle[] = [] + const indicator: ViewStyle[] = [] + + if (selected) { + base.push({ + backgroundColor: + t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900, + borderColor: t.palette.primary_500, + }) + + if (hovered || focused) { + baseHover.push({ + backgroundColor: + t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800, + borderColor: + t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400, + }) + } + } else { + if (hovered || focused) { + baseHover.push({ + backgroundColor: + t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100, + borderColor: t.palette.contrast_500, + }) + } + } + + if (isInvalid) { + base.push({ + backgroundColor: + t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, + borderColor: + t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800, + }) + + if (hovered || focused) { + baseHover.push({ + backgroundColor: + t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900, + borderColor: t.palette.negative_500, + }) + } + } + + if (disabled) { + base.push({ + backgroundColor: t.palette.contrast_100, + borderColor: t.palette.contrast_400, + }) + } + + return { + baseStyles: base, + baseHoverStyles: disabled ? [] : baseHover, + indicatorStyles: indicator, + } +} + +export function Checkbox() { + const t = useTheme() + const {selected, hovered, focused, disabled, isInvalid} = useItemContext() + const {baseStyles, baseHoverStyles, indicatorStyles} = + createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, + }) + return ( + <View + style={[ + a.justify_center, + a.align_center, + a.border, + a.rounded_xs, + t.atoms.border_contrast, + { + height: 20, + width: 20, + }, + baseStyles, + hovered || focused ? baseHoverStyles : {}, + ]}> + {selected ? ( + <View + style={[ + a.absolute, + a.rounded_2xs, + {height: 12, width: 12}, + selected + ? { + backgroundColor: t.palette.primary_500, + } + : {}, + indicatorStyles, + ]} + /> + ) : null} + </View> + ) +} + +export function Switch() { + const t = useTheme() + const {selected, hovered, focused, disabled, isInvalid} = useItemContext() + const {baseStyles, baseHoverStyles, indicatorStyles} = + createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, + }) + return ( + <View + style={[ + a.relative, + a.border, + a.rounded_full, + t.atoms.bg, + t.atoms.border_contrast, + { + height: 20, + width: 30, + }, + baseStyles, + hovered || focused ? baseHoverStyles : {}, + ]}> + <View + style={[ + a.absolute, + a.rounded_full, + { + height: 12, + width: 12, + top: 3, + left: 3, + backgroundColor: t.palette.contrast_400, + }, + selected + ? { + backgroundColor: t.palette.primary_500, + left: 13, + } + : {}, + indicatorStyles, + ]} + /> + </View> + ) +} + +export function Radio() { + const t = useTheme() + const {selected, hovered, focused, disabled, isInvalid} = + React.useContext(ItemContext) + const {baseStyles, baseHoverStyles, indicatorStyles} = + createSharedToggleStyles({ + theme: t, + hovered, + focused, + selected, + disabled, + isInvalid, + }) + return ( + <View + style={[ + a.justify_center, + a.align_center, + a.border, + a.rounded_full, + t.atoms.border_contrast, + { + height: 20, + width: 20, + }, + baseStyles, + hovered || focused ? baseHoverStyles : {}, + ]}> + {selected ? ( + <View + style={[ + a.absolute, + a.rounded_full, + {height: 12, width: 12}, + selected + ? { + backgroundColor: t.palette.primary_500, + } + : {}, + indicatorStyles, + ]} + /> + ) : null} + </View> + ) +} diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx new file mode 100644 index 000000000..615fedae8 --- /dev/null +++ b/src/components/forms/ToggleButton.tsx @@ -0,0 +1,124 @@ +import React from 'react' +import {View, AccessibilityProps, TextStyle, ViewStyle} from 'react-native' + +import {atoms as a, useTheme, native} from '#/alf' +import {Text} from '#/components/Typography' + +import * as Toggle from '#/components/forms/Toggle' + +export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> & + AccessibilityProps & + React.PropsWithChildren<{}> + +export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & { + multiple?: boolean +} + +export function Group({children, multiple, ...props}: GroupProps) { + const t = useTheme() + return ( + <Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}> + <View + style={[ + a.flex_row, + a.border, + a.rounded_sm, + a.overflow_hidden, + t.atoms.border, + ]}> + {children} + </View> + </Toggle.Group> + ) +} + +export function Button({children, ...props}: ItemProps) { + return ( + <Toggle.Item {...props}> + <ButtonInner>{children}</ButtonInner> + </Toggle.Item> + ) +} + +function ButtonInner({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + const state = Toggle.useItemContext() + + const {baseStyles, hoverStyles, activeStyles, textStyles} = + React.useMemo(() => { + const base: ViewStyle[] = [] + const hover: ViewStyle[] = [] + const active: ViewStyle[] = [] + const text: TextStyle[] = [] + + hover.push( + t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25, + ) + + if (state.selected) { + active.push({ + backgroundColor: t.palette.contrast_800, + }) + text.push(t.atoms.text_inverted) + hover.push({ + backgroundColor: t.palette.contrast_800, + }) + + if (state.disabled) { + active.push({ + backgroundColor: t.palette.contrast_500, + }) + } + } + + if (state.disabled) { + base.push({ + backgroundColor: t.palette.contrast_100, + }) + text.push({ + opacity: 0.5, + }) + } + + return { + baseStyles: base, + hoverStyles: hover, + activeStyles: active, + textStyles: text, + } + }, [t, state]) + + return ( + <View + style={[ + { + borderLeftWidth: 1, + marginLeft: -1, + }, + a.px_lg, + a.py_md, + native({ + paddingTop: 14, + }), + t.atoms.bg, + t.atoms.border, + baseStyles, + activeStyles, + (state.hovered || state.focused || state.pressed) && hoverStyles, + ]}> + {typeof children === 'string' ? ( + <Text + style={[ + a.text_center, + a.font_bold, + t.atoms.text_contrast_500, + textStyles, + ]}> + {children} + </Text> + ) : ( + children + )} + </View> + ) +} diff --git a/src/components/hooks/useInteractionState.ts b/src/components/hooks/useInteractionState.ts new file mode 100644 index 000000000..653b1c10e --- /dev/null +++ b/src/components/hooks/useInteractionState.ts @@ -0,0 +1,21 @@ +import React from 'react' + +export function useInteractionState() { + const [state, setState] = React.useState(false) + + const onIn = React.useCallback(() => { + setState(true) + }, [setState]) + const onOut = React.useCallback(() => { + setState(false) + }, [setState]) + + return React.useMemo( + () => ({ + state, + onIn, + onOut, + }), + [state, onIn, onOut], + ) +} diff --git a/src/components/icons/ArrowTopRight.tsx b/src/components/icons/ArrowTopRight.tsx new file mode 100644 index 000000000..92ad30a12 --- /dev/null +++ b/src/components/icons/ArrowTopRight.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z', +}) diff --git a/src/components/icons/CalendarDays.tsx b/src/components/icons/CalendarDays.tsx new file mode 100644 index 000000000..72cc48e26 --- /dev/null +++ b/src/components/icons/CalendarDays.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const CalendarDays_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z', +}) diff --git a/src/components/icons/ColorPalette.tsx b/src/components/icons/ColorPalette.tsx new file mode 100644 index 000000000..157fa7fa1 --- /dev/null +++ b/src/components/icons/ColorPalette.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const ColorPalette_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z', +}) diff --git a/src/components/icons/Globe.tsx b/src/components/icons/Globe.tsx new file mode 100644 index 000000000..f81b3ff7a --- /dev/null +++ b/src/components/icons/Globe.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Globe_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z', +}) diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx new file mode 100644 index 000000000..9fc147037 --- /dev/null +++ b/src/components/icons/TEMPLATE.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import Svg, {Path} from 'react-native-svg' + +import {useCommonSVGProps, Props} from '#/components/icons/common' + +export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef( + function LogoImpl(props: Props, ref) { + const {fill, size, style, ...rest} = useCommonSVGProps(props) + + return ( + <Svg + fill="none" + {...rest} + // @ts-ignore it's fiiiiine + ref={ref} + viewBox="0 0 24 24" + width={size} + height={size} + style={[style]}> + <Path + fill={fill} + fillRule="evenodd" + clipRule="evenodd" + d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z" + /> + </Svg> + ) + }, +) + +export function createSinglePathSVG({path}: {path: string}) { + return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) { + const {fill, size, style, ...rest} = useCommonSVGProps(props) + + return ( + <Svg + fill="none" + {...rest} + ref={ref} + viewBox="0 0 24 24" + width={size} + height={size} + style={[style]}> + <Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} /> + </Svg> + ) + }) +} diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts new file mode 100644 index 000000000..9e9f15c4d --- /dev/null +++ b/src/components/icons/common.ts @@ -0,0 +1,32 @@ +import {StyleSheet, TextProps} from 'react-native' +import type {SvgProps, PathProps} from 'react-native-svg' + +import {tokens} from '#/alf' + +export type Props = { + fill?: PathProps['fill'] + style?: TextProps['style'] + size?: keyof typeof sizes +} & Omit<SvgProps, 'style' | 'size'> + +export const sizes = { + xs: 12, + sm: 16, + md: 20, + lg: 24, + xl: 28, +} + +export function useCommonSVGProps(props: Props) { + const {fill, size, ...rest} = props + const style = StyleSheet.flatten(rest.style) + const _fill = fill || style?.color || tokens.color.blue_500 + const _size = Number(size ? sizes[size] : rest.width || sizes.md) + + return { + fill: _fill, + size: _size, + style, + ...rest, + } +} |