From febca8b1bdf2d19b0dfb7c8d685a81fe2fa1a48c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Qu=E1=BB=91c=20Kh=C3=A1nh?= Date: Mon, 17 Jun 2024 00:36:48 +0700 Subject: [PATCH] feat(mobile): adopt rn-reusables (#64) - Migrate all `nativecn` components to `react-native-reusables`. - RN Text -> `@components/ui/text` Docs: https://rnr-docs.vercel.app To add new component: ``` cd apps/mobile ``` ``` npx @react-native-reusables/cli@latest add ``` --- apps/mobile/app/(app)/(tabs)/_layout.tsx | 2 +- apps/mobile/app/(app)/(tabs)/settings.tsx | 33 +- apps/mobile/app/(app)/_layout.tsx | 2 +- apps/mobile/app/(app)/appearance.tsx | 36 +-- apps/mobile/app/(app)/language.tsx | 5 +- apps/mobile/app/(auth)/login.tsx | 22 +- apps/mobile/app/+not-found.tsx | 9 +- apps/mobile/app/_layout.tsx | 2 +- apps/mobile/components.json | 7 + apps/mobile/components/Avatar.tsx | 60 ---- apps/mobile/components/Badge.tsx | 59 ---- apps/mobile/components/Button.tsx | 113 ------- apps/mobile/components/Card.tsx | 119 ------- apps/mobile/components/Checkbox.tsx | 50 --- apps/mobile/components/Dialog.tsx | 74 ----- apps/mobile/components/DropDown.tsx | 97 ------ apps/mobile/components/IconButton.tsx | 90 ------ apps/mobile/components/Input.tsx | 44 --- apps/mobile/components/ParallaxScrollView.tsx | 79 ----- apps/mobile/components/Progress.tsx | 42 --- apps/mobile/components/RadioGroup.tsx | 90 ------ apps/mobile/components/Separator.tsx | 38 --- apps/mobile/components/Skeleton.tsx | 38 --- apps/mobile/components/Switch.tsx | 29 -- apps/mobile/components/Tabs.tsx | 115 ------- apps/mobile/components/Toast.tsx | 178 ----------- apps/mobile/components/auth/auth-email.tsx | 23 +- apps/mobile/components/auth/auth-social.tsx | 8 +- apps/mobile/components/common/menu-item.tsx | 7 +- apps/mobile/components/common/toolbar.tsx | 26 +- .../components/form-fields/input-field.tsx | 39 ++- .../components/form-fields/submit-button.tsx | 2 +- apps/mobile/components/home/header.tsx | 10 +- .../components/primitives/avatar/index.tsx | 119 +++++++ .../components/primitives/avatar/types.ts | 10 + .../components/primitives/label/index.ts | 1 + .../components/primitives/label/label.tsx | 31 ++ .../components/primitives/label/label.web.tsx | 36 +++ .../components/primitives/label/types.ts | 15 + .../components/primitives/separator/index.tsx | 23 ++ .../components/primitives/separator/types.ts | 6 + apps/mobile/components/primitives/slot.tsx | 218 +++++++++++++ .../components/primitives/tabs/index.ts | 1 + .../components/primitives/tabs/tabs.tsx | 147 +++++++++ .../components/primitives/tabs/tabs.web.tsx | 97 ++++++ .../components/primitives/tabs/types.ts | 24 ++ apps/mobile/components/primitives/types.ts | 106 ++++++ apps/mobile/components/ui/avatar.tsx | 44 +++ apps/mobile/components/ui/badge.tsx | 51 +++ apps/mobile/components/ui/button.tsx | 88 +++++ apps/mobile/components/ui/input.tsx | 25 ++ apps/mobile/components/ui/label.tsx | 28 ++ apps/mobile/components/ui/separator.tsx | 23 ++ apps/mobile/components/ui/tabs.tsx | 65 ++++ apps/mobile/components/ui/text.tsx | 28 ++ apps/mobile/global.css | 2 +- apps/mobile/hooks/useColorScheme.ts | 14 +- apps/mobile/hooks/useColorScheme.web.ts | 8 - apps/mobile/lib/icons/iconWithClassName.ts | 14 + apps/mobile/package.json | 2 + pnpm-lock.yaml | 301 ++++++++++++++++++ 61 files changed, 1663 insertions(+), 1412 deletions(-) create mode 100644 apps/mobile/components.json delete mode 100644 apps/mobile/components/Avatar.tsx delete mode 100644 apps/mobile/components/Badge.tsx delete mode 100644 apps/mobile/components/Button.tsx delete mode 100644 apps/mobile/components/Card.tsx delete mode 100644 apps/mobile/components/Checkbox.tsx delete mode 100644 apps/mobile/components/Dialog.tsx delete mode 100644 apps/mobile/components/DropDown.tsx delete mode 100644 apps/mobile/components/IconButton.tsx delete mode 100644 apps/mobile/components/Input.tsx delete mode 100644 apps/mobile/components/ParallaxScrollView.tsx delete mode 100644 apps/mobile/components/Progress.tsx delete mode 100644 apps/mobile/components/RadioGroup.tsx delete mode 100644 apps/mobile/components/Separator.tsx delete mode 100644 apps/mobile/components/Skeleton.tsx delete mode 100644 apps/mobile/components/Switch.tsx delete mode 100644 apps/mobile/components/Tabs.tsx delete mode 100644 apps/mobile/components/Toast.tsx create mode 100644 apps/mobile/components/primitives/avatar/index.tsx create mode 100644 apps/mobile/components/primitives/avatar/types.ts create mode 100644 apps/mobile/components/primitives/label/index.ts create mode 100644 apps/mobile/components/primitives/label/label.tsx create mode 100644 apps/mobile/components/primitives/label/label.web.tsx create mode 100644 apps/mobile/components/primitives/label/types.ts create mode 100644 apps/mobile/components/primitives/separator/index.tsx create mode 100644 apps/mobile/components/primitives/separator/types.ts create mode 100644 apps/mobile/components/primitives/slot.tsx create mode 100644 apps/mobile/components/primitives/tabs/index.ts create mode 100644 apps/mobile/components/primitives/tabs/tabs.tsx create mode 100644 apps/mobile/components/primitives/tabs/tabs.web.tsx create mode 100644 apps/mobile/components/primitives/tabs/types.ts create mode 100644 apps/mobile/components/primitives/types.ts create mode 100644 apps/mobile/components/ui/avatar.tsx create mode 100644 apps/mobile/components/ui/badge.tsx create mode 100644 apps/mobile/components/ui/button.tsx create mode 100644 apps/mobile/components/ui/input.tsx create mode 100644 apps/mobile/components/ui/label.tsx create mode 100644 apps/mobile/components/ui/separator.tsx create mode 100644 apps/mobile/components/ui/tabs.tsx create mode 100644 apps/mobile/components/ui/text.tsx delete mode 100644 apps/mobile/hooks/useColorScheme.web.ts create mode 100644 apps/mobile/lib/icons/iconWithClassName.ts diff --git a/apps/mobile/app/(app)/(tabs)/_layout.tsx b/apps/mobile/app/(app)/(tabs)/_layout.tsx index 563f0aa3..4cde6049 100644 --- a/apps/mobile/app/(app)/(tabs)/_layout.tsx +++ b/apps/mobile/app/(app)/(tabs)/_layout.tsx @@ -6,7 +6,7 @@ import { Tabs } from 'expo-router' import { CogIcon, LandPlotIcon, ScanTextIcon, WalletIcon } from 'lucide-react-native' export default function TabLayout() { - const colorScheme = useColorScheme() + const { colorScheme } = useColorScheme() const { i18n } = useLingui() return ( - + + > + Free + {user?.fullName ?? user?.primaryEmailAddress?.emailAddress} - + @@ -75,7 +78,6 @@ export default function SettingsScreen() { {t(i18n)`Others`} ) diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 2e47fe99..48b9d66a 100644 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -8,7 +8,7 @@ import { useEffect } from 'react' export default function AuthenticatedLayout() { const { isLoaded, isSignedIn } = useAuth() - const colorScheme = useColorScheme() + const { colorScheme } = useColorScheme() const { i18n } = useLingui() useEffect(() => { diff --git a/apps/mobile/app/(app)/appearance.tsx b/apps/mobile/app/(app)/appearance.tsx index 59f54f6b..42a5ce2c 100644 --- a/apps/mobile/app/(app)/appearance.tsx +++ b/apps/mobile/app/(app)/appearance.tsx @@ -1,9 +1,10 @@ -import { Tabs, TabsList, TabsTrigger } from '@/components/Tabs' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Text } from '@/components/ui/text' +import { useColorScheme } from '@/hooks/useColorScheme' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' -import { MoonStarIcon, SmartphoneIcon, SunIcon } from 'lucide-react-native' -import { useColorScheme } from 'nativewind' -import { ScrollView, Text } from 'react-native' +import { MoonStarIcon, SunIcon } from 'lucide-react-native' +import { ScrollView } from 'react-native' export default function AppearanceScreen() { const { colorScheme, setColorScheme } = useColorScheme() @@ -18,26 +19,19 @@ export default function AppearanceScreen() { {t(i18n)`Choose a preferred theme for the 6pm`} - onChange={(value: any) => setColorScheme(value)} + onValueChange={(value: any) => setColorScheme(value)} > - - - + + + {t(i18n)`Light`} + + + + {t(i18n)`Dark`} + diff --git a/apps/mobile/app/(app)/language.tsx b/apps/mobile/app/(app)/language.tsx index cd7f2c77..8279e889 100644 --- a/apps/mobile/app/(app)/language.tsx +++ b/apps/mobile/app/(app)/language.tsx @@ -1,10 +1,11 @@ import { MenuItem } from '@/components/common/menu-item' +import { Text } from '@/components/ui/text' import { useLocale } from '@/locales/provider' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import { useRouter } from 'expo-router' import { CheckCircleIcon } from 'lucide-react-native' -import { ScrollView, Text } from 'react-native' +import { ScrollView } from 'react-native' export default function LanguageScreen() { const { i18n } = useLingui() @@ -13,7 +14,7 @@ export default function LanguageScreen() { return ( - + {t(i18n)`Language`} {withEmail && } By continuing, you acknowledge that you understand and agree to the{' '} - - Terms & Conditions + + Terms & Conditions {' '} and{' '} - - Privacy Policy + + Privacy Policy diff --git a/apps/mobile/app/+not-found.tsx b/apps/mobile/app/+not-found.tsx index 1b82d0e9..918d6dba 100644 --- a/apps/mobile/app/+not-found.tsx +++ b/apps/mobile/app/+not-found.tsx @@ -1,7 +1,8 @@ import { Link, Stack } from 'expo-router' -import { Text, View } from 'react-native' +import { View } from 'react-native' -import { Button } from '@/components/Button' +import { Button } from '@/components/ui/button' +import { Text } from '@/components/ui/text' export default function NotFoundScreen() { return ( @@ -10,7 +11,9 @@ export default function NotFoundScreen() { This screen doesn't exist. - diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 3f1e1ceb..680a30cb 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -47,7 +47,7 @@ export const unstable_settings = { export default function RootLayout() { useWarmUpBrowser(); - const colorScheme = useColorScheme() + const { colorScheme } = useColorScheme() const [fontsLoaded] = useFonts({ BeVietnamPro_300Light, BeVietnamPro_400Regular, diff --git a/apps/mobile/components.json b/apps/mobile/components.json new file mode 100644 index 00000000..2e94b220 --- /dev/null +++ b/apps/mobile/components.json @@ -0,0 +1,7 @@ +{ + "platforms": "universal", + "aliases": { + "components": "@/components", + "lib": "@/lib" + } +} \ No newline at end of file diff --git a/apps/mobile/components/Avatar.tsx b/apps/mobile/components/Avatar.tsx deleted file mode 100644 index 6796600e..00000000 --- a/apps/mobile/components/Avatar.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { forwardRef, useState } from 'react' -import { Image, Text, View } from 'react-native' - -import { cn } from '../lib/utils' - -const Avatar = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -Avatar.displayName = 'Avatar' - -const AvatarImage = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const [hasError, setHasError] = useState(false) - - if (hasError) { - return null - } - return ( - setHasError(true)} - className={cn('aspect-square h-full w-full', className)} - {...props} - /> - ) -}) -AvatarImage.displayName = 'AvatarImage' - -const AvatarFallback = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { textClassname?: string } ->(({ children, className, textClassname, ...props }, ref) => ( - - - {children} - - -)) -AvatarFallback.displayName = 'AvatarFallback' - -export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/mobile/components/Badge.tsx b/apps/mobile/components/Badge.tsx deleted file mode 100644 index c56a6769..00000000 --- a/apps/mobile/components/Badge.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import { Text, View } from 'react-native' - -import { cn } from '../lib/utils' - -const badgeVariants = cva( - 'flex flex-row items-center rounded-full px-2 py-0.5 text-xs font-semibold', - { - variants: { - variant: { - default: 'bg-primary', - secondary: 'bg-secondary border border-border', - destructive: 'bg-destructive', - success: 'bg-green-500 dark:bg-green-700', - }, - }, - defaultVariants: { - variant: 'default', - }, - }, -) - -const badgeTextVariants = cva('font-medium text-center text-xs', { - variants: { - variant: { - default: 'text-primary-foreground', - secondary: 'text-muted-foreground', - destructive: 'text-destructive-foreground', - success: 'text-green-100', - }, - }, - defaultVariants: { - variant: 'default', - }, -}) - -export interface BadgeProps - extends React.ComponentPropsWithoutRef, - VariantProps { - label: string - labelClasses?: string -} -function Badge({ - label, - labelClasses, - className, - variant, - ...props -}: BadgeProps) { - return ( - - - {label} - - - ) -} - -export { Badge, badgeVariants } diff --git a/apps/mobile/components/Button.tsx b/apps/mobile/components/Button.tsx deleted file mode 100644 index e93d399d..00000000 --- a/apps/mobile/components/Button.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import { Text, TouchableOpacity } from 'react-native' - -import { forwardRef } from 'react' -import type { SvgProps } from 'react-native-svg' -import { cn } from '../lib/utils' - -const buttonVariants = cva( - 'flex flex-row items-center gap-2 justify-center rounded-md', - { - variants: { - variant: { - default: 'bg-primary', - secondary: 'bg-secondary', - outline: 'border-border border', - destructive: 'bg-destructive', - ghost: 'bg-transparent', - link: 'text-primary underline-offset-4', - }, - size: { - default: 'h-12 px-4', - sm: 'h-8 px-2', - lg: 'h-14 px-8', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, - }, -) - -const buttonTextVariants = cva('text-center font-medium font-sans', { - variants: { - variant: { - default: 'text-primary-foreground', - secondary: 'text-secondary-foreground', - outline: 'text-primary', - destructive: 'text-destructive-foreground', - ghost: 'text-primary', - link: 'text-primary-foreground underline', - }, - size: { - default: 'text-base', - sm: 'text-sm', - lg: 'text-xl', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, -}) - -export interface ButtonProps - extends React.ComponentPropsWithoutRef, - VariantProps { - label: string - labelClasses?: string - leftIcon?: React.ComponentType - rightIcon?: React.ComponentType -} - -const Button = forwardRef(function ({ - label, - labelClasses, - className, - variant, - size, - leftIcon: LeftIcon, - rightIcon: RightIcon, - disabled, - ...props -}: ButtonProps, ref: React.ForwardedRef) { - return ( - - {LeftIcon && ( - - )} - - {label} - - {RightIcon && ( - - )} - - ) -}) - -export { Button, buttonVariants, buttonTextVariants } diff --git a/apps/mobile/components/Card.tsx b/apps/mobile/components/Card.tsx deleted file mode 100644 index d3e3fead..00000000 --- a/apps/mobile/components/Card.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { Text, View } from 'react-native' - -import { cn } from '../lib/utils' - -function Card({ - className, - ...props -}: React.ComponentPropsWithoutRef) { - return ( - - ) -} - -function CardHeader({ - className, - ...props -}: React.ComponentPropsWithoutRef) { - return -} - -function CardTitle({ - className, - ...props -}: React.ComponentPropsWithoutRef) { - return ( - - ) -} - -function CardDescription({ - className, - ...props -}: React.ComponentPropsWithoutRef) { - return ( - - ) -} - -function CardContent({ - className, - ...props -}: React.ComponentPropsWithoutRef) { - return -} - -// TODO: style -function CardFooter({ - className, - ...props -}: React.ComponentPropsWithoutRef) { - return ( - - ) -} - -interface SimpleCardProps { - className?: string - title?: string - description?: string - content?: string - footer?: string -} -function SimpleCard({ - className, - title, - description, - content, - footer, -}: SimpleCardProps) { - return ( - - - {title && ( - - {title} - - )} - {description && ( - {description} - )} - - {content && ( - - {content} - - )} - {footer && ( - - {footer} - - )} - - ) -} - -export { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - CardFooter, - SimpleCard, -} diff --git a/apps/mobile/components/Checkbox.tsx b/apps/mobile/components/Checkbox.tsx deleted file mode 100644 index da159c88..00000000 --- a/apps/mobile/components/Checkbox.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from 'react' -import { Text, TouchableOpacity, View } from 'react-native' - -import { cn } from '../lib/utils' - -// TODO: make controlled (optional) -interface CheckboxProps extends React.ComponentPropsWithoutRef { - label?: string - labelClasses?: string - checkboxClasses?: string -} -function Checkbox({ - label, - labelClasses, - checkboxClasses, - className, - ...props -}: CheckboxProps) { - const [isChecked, setChecked] = useState(false) - - const toggleCheckbox = () => { - setChecked((prev) => !prev) - } - - return ( - - - - {isChecked && } - - - {label && ( - {label} - )} - - ) -} - -export { Checkbox } diff --git a/apps/mobile/components/Dialog.tsx b/apps/mobile/components/Dialog.tsx deleted file mode 100644 index e109dd38..00000000 --- a/apps/mobile/components/Dialog.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { cloneElement, createContext, useContext, useState } from 'react' -import { Modal, TouchableOpacity, View } from 'react-native' - -import { cn } from '../lib/utils' - -interface DialogContextType { - open: boolean - setOpen: (open: boolean) => void -} - -const DialogContext = createContext(undefined) - -function Dialog({ children }: { children: React.ReactNode }) { - const [open, setOpen] = useState(false) - - return ( - - {children} - - ) -} - -// biome-ignore lint/suspicious/noExplicitAny: -function DialogTrigger({ children }: any) { - const { setOpen } = useDialog() - - return cloneElement(children, { onPress: () => setOpen(true) }) -} - -function DialogContent({ - className, - children, -}: { - className?: string - children: React.ReactNode -}) { - const { open, setOpen } = useDialog() - - return ( - setOpen(false)} - > - setOpen(false)} - > - - - {children} - - - - - ) -} - -const useDialog = () => { - const context = useContext(DialogContext) - if (!context) { - throw new Error('useDialog must be used within a DialogProvider') - } - return context -} - -export { Dialog, DialogTrigger, DialogContent, useDialog } diff --git a/apps/mobile/components/DropDown.tsx b/apps/mobile/components/DropDown.tsx deleted file mode 100644 index 000c5e3d..00000000 --- a/apps/mobile/components/DropDown.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type React from 'react' -import { cloneElement, createContext, useContext, useState } from 'react' -import { Text, View } from 'react-native' - -import { cn } from '../lib/utils' - -interface DropDownContextType { - open: boolean - setOpen: (open: boolean) => void -} - -const DropDownContext = createContext( - undefined, -) - -const DropDown = ({ children }: { children: React.ReactNode }) => { - const [open, setOpen] = useState(false) - return ( - - {children} - - ) -} - -// biome-ignore lint/suspicious/noExplicitAny: -const DropDownTrigger = ({ children }: any) => { - const { open, setOpen } = useDropdown() - return cloneElement(children, { - onPress: () => setOpen(!open), - }) -} - -type DropDownContentTypes = { - className?: string - children: React.ReactNode -} - -const DropDownContent = ({ className, children }: DropDownContentTypes) => { - const { open } = useDropdown() - return ( - <> - {open && ( - - {children} - - )} - - ) -} - -type DropDownLabelProps = { - labelTitle: string -} - -const DropDownLabel = ({ labelTitle }: DropDownLabelProps) => { - return ( - {labelTitle} - ) -} - -type DropDownItemProps = { - children: React.ReactNode - className?: string -} - -const DropDownItem = ({ children, className }: DropDownItemProps) => { - return ( - - {children} - - ) -} - -const DropDownItemSeparator = () => { - return -} -const useDropdown = () => { - const context = useContext(DropDownContext) - if (!context) { - throw new Error('useDropdown must be used within a DropdownProvider') - } - return context -} -export { - DropDown, - DropDownTrigger, - DropDownContent, - DropDownLabel, - DropDownItemSeparator, - DropDownItem, - useDropdown, -} diff --git a/apps/mobile/components/IconButton.tsx b/apps/mobile/components/IconButton.tsx deleted file mode 100644 index d25e69ff..00000000 --- a/apps/mobile/components/IconButton.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import { TouchableOpacity } from 'react-native' - -import { forwardRef } from 'react' -import type { SvgProps } from 'react-native-svg' -import { cn } from '../lib/utils' - -const buttonVariants = cva( - 'flex flex-row items-center gap-2 justify-center rounded-md', - { - variants: { - variant: { - default: 'bg-primary', - secondary: 'bg-secondary', - outline: 'border-border border', - destructive: 'bg-destructive', - ghost: 'bg-transparent', - link: 'text-primary underline-offset-4', - }, - size: { - default: 'h-10 w-10', - lg: 'h-12 w-12', - sm: 'h-8 w-8', - xl: 'h-14 w-14', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, - }, -) - -const iconVariants = cva('text-center font-medium font-sans', { - variants: { - variant: { - default: 'text-primary-foreground', - secondary: 'text-secondary-foreground', - outline: 'text-primary', - destructive: 'text-destructive-foreground', - ghost: 'text-primary', - link: 'text-primary-foreground underline', - }, - size: { - default: 'w-5 h-5', - sm: 'w-5 h-5', - lg: 'w-6 h-6', - xl: 'w-6 h-6', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, -}) - -export interface IconButtonProps - extends React.ComponentPropsWithoutRef, - VariantProps { - icon: React.ComponentType - iconClasses?: string -} - -const IconButton = forwardRef(function ({ - icon: Icon, - iconClasses, - className, - variant, - size, - disabled, - ...props -}: IconButtonProps, ref: React.ForwardedRef) { - return ( - - - - ) -}) - -export { IconButton, buttonVariants, iconVariants } diff --git a/apps/mobile/components/Input.tsx b/apps/mobile/components/Input.tsx deleted file mode 100644 index a9a226c7..00000000 --- a/apps/mobile/components/Input.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { forwardRef } from 'react' -import { Text, TextInput, View } from 'react-native' - -import { cn } from '../lib/utils' - -export interface InputProps - extends React.ComponentPropsWithoutRef { - label?: string - labelClasses?: string - inputClasses?: string - leftSection?: React.ReactNode - rightSection?: React.ReactNode -} -const Input = forwardRef, InputProps>( - // biome-ignore lint/correctness/noUnusedVariables: - ({ className, label, labelClasses, inputClasses, leftSection, rightSection, ...props }, ref) => ( - - {label && {label}} - - {leftSection && ( - - {leftSection} - - )} - - {rightSection && ( - - {rightSection} - - )} - - - ), -) - -export { Input } diff --git a/apps/mobile/components/ParallaxScrollView.tsx b/apps/mobile/components/ParallaxScrollView.tsx deleted file mode 100644 index 3000fbf6..00000000 --- a/apps/mobile/components/ParallaxScrollView.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { PropsWithChildren, ReactElement } from 'react' -import { StyleSheet, View, useColorScheme } from 'react-native' -import Animated, { - interpolate, - useAnimatedRef, - useAnimatedStyle, - useScrollViewOffset, -} from 'react-native-reanimated' - -const HEADER_HEIGHT = 250 - -type Props = PropsWithChildren<{ - headerImage: ReactElement - headerBackgroundColor: { dark: string; light: string } -}> - -export default function ParallaxScrollView({ - children, - headerImage, - headerBackgroundColor, -}: Props) { - const colorScheme = useColorScheme() ?? 'light' - const scrollRef = useAnimatedRef() - const scrollOffset = useScrollViewOffset(scrollRef) - - const headerAnimatedStyle = useAnimatedStyle(() => { - return { - transform: [ - { - translateY: interpolate( - scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], - [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75], - ), - }, - { - scale: interpolate( - scrollOffset.value, - [-HEADER_HEIGHT, 0, HEADER_HEIGHT], - [2, 1, 1], - ), - }, - ], - } - }) - - return ( - - - - {headerImage} - - {children} - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - height: 250, - overflow: 'hidden', - }, - content: { - flex: 1, - padding: 32, - gap: 16, - overflow: 'hidden', - }, -}) diff --git a/apps/mobile/components/Progress.tsx b/apps/mobile/components/Progress.tsx deleted file mode 100644 index 731f8507..00000000 --- a/apps/mobile/components/Progress.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect, useRef } from 'react' -import { Animated, View as RnView, type View } from 'react-native' - -import { cn } from '../lib/utils' - -function Progress({ - className, - ...props -}: { className?: string; value: number } & React.ComponentPropsWithoutRef< - typeof View ->) { - const widthAnim = useRef(new Animated.Value(0)).current - - useEffect(() => { - Animated.timing(widthAnim, { - toValue: props.value, - duration: 1000, - useNativeDriver: false, - }).start() - }, [widthAnim, props.value]) - - return ( - - - - ) -} - -export { Progress } diff --git a/apps/mobile/components/RadioGroup.tsx b/apps/mobile/components/RadioGroup.tsx deleted file mode 100644 index 9c6eb7a4..00000000 --- a/apps/mobile/components/RadioGroup.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Circle, CircleDot } from 'lucide-react-native' -import { createContext, useContext, useState } from 'react' -import { Text, TouchableOpacity, useColorScheme } from 'react-native' - -import { theme } from '../lib/theme' -import { cn } from '../lib/utils' - -interface RadioGroupContextType { - value: string - setValue: (value: string) => void -} -const RadioGroupContext = createContext( - undefined, -) - -interface RadioGroupProps { - defaultValue: string - children: React.ReactNode -} -function RadioGroup({ defaultValue, children }: RadioGroupProps) { - const [value, setValue] = useState(defaultValue) - - return ( - - {children} - - ) -} - -interface RadioGroupItemProps - extends React.ComponentPropsWithoutRef { - value: string - label?: string - labelClasses?: string -} -function RadioGroupItem({ - value, - className, - label, - labelClasses, - ...props -}: RadioGroupItemProps) { - const context = useContext(RadioGroupContext) - if (!context) { - throw new Error('RadioGroupItem must be used within a RadioGroup') - } - const { value: selectedValue, setValue } = context - - const colorScheme = useColorScheme() - const currentTheme = colorScheme === 'dark' ? theme.dark : theme.light - - return ( - setValue(value)} - className={cn('flex flex-row items-center gap-2', className)} - {...props} - > - {selectedValue === value ? ( - - ) : ( - - )} - {label && ( - {label} - )} - - ) -} - -interface RadioGroupLabelProps - extends React.ComponentPropsWithoutRef { - value: string -} -function RadioGroupLabel({ value, className, ...props }: RadioGroupLabelProps) { - const context = useContext(RadioGroupContext) - if (!context) { - throw new Error('RadioGroupLabel must be used within a RadioGroup') - } - const { setValue } = context - - return ( - setValue(value)} - {...props} - /> - ) -} - -export { RadioGroup, RadioGroupItem, RadioGroupLabel } diff --git a/apps/mobile/components/Separator.tsx b/apps/mobile/components/Separator.tsx deleted file mode 100644 index 94d5bb57..00000000 --- a/apps/mobile/components/Separator.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { type VariantProps, cva } from 'class-variance-authority' -import { View } from 'react-native' - -import { cn } from '../lib/utils' - -const separatorVariants = cva( - 'bg-border shrink-0', - { - variants: { - variant: { - horizontal: 'h-[1] w-full', - vertical: 'w-[1] h-full' - }, - }, - defaultVariants: { - variant: 'horizontal', - }, - }, -) - -interface SeparatorProps - extends React.ComponentPropsWithoutRef, - VariantProps { -} -function Separator({ - className, - variant, - ...props -}: SeparatorProps) { - return ( - - ) -} - -export { Separator, separatorVariants } diff --git a/apps/mobile/components/Skeleton.tsx b/apps/mobile/components/Skeleton.tsx deleted file mode 100644 index f497658a..00000000 --- a/apps/mobile/components/Skeleton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect, useRef } from 'react' -import { Animated, type View } from 'react-native' - -import { cn } from '../lib/utils' - -function Skeleton({ - className, - ...props -}: { className?: string } & React.ComponentPropsWithoutRef) { - const fadeAnim = useRef(new Animated.Value(0.5)).current - - useEffect(() => { - Animated.loop( - Animated.sequence([ - Animated.timing(fadeAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }), - Animated.timing(fadeAnim, { - toValue: 0.5, - duration: 1000, - useNativeDriver: true, - }), - ]), - ).start() - }, [fadeAnim]) - - return ( - - ) -} - -export { Skeleton } diff --git a/apps/mobile/components/Switch.tsx b/apps/mobile/components/Switch.tsx deleted file mode 100644 index 7a1192f7..00000000 --- a/apps/mobile/components/Switch.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Switch as NativeSwitch, useColorScheme } from 'react-native' - -import { theme } from '../lib/theme' - -function Switch({ - ...props -}: React.ComponentPropsWithoutRef) { - const colorScheme = useColorScheme() - const currentTheme = colorScheme === 'dark' ? theme.dark : theme.light - - const trackColor = props.trackColor || { - false: currentTheme.background, - true: currentTheme.foreground, - } - const thumbColor = props.thumbColor || currentTheme.background - const iosBackgroundColor = - props.ios_backgroundColor || currentTheme.background - - return ( - - ) -} - -export { Switch } diff --git a/apps/mobile/components/Tabs.tsx b/apps/mobile/components/Tabs.tsx deleted file mode 100644 index 9881be20..00000000 --- a/apps/mobile/components/Tabs.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { createContext, useContext, useState } from 'react' -import { Text, TouchableOpacity, View } from 'react-native' - -import type { LucideIcon } from 'lucide-react-native' -import { cn } from '../lib/utils' - -interface TabsContextProps { - activeTab: string - setActiveTab: (id: string) => void -} -const TabsContext = createContext({ - activeTab: '', - // biome-ignore lint/suspicious/noEmptyBlockStatements: - setActiveTab: () => { }, -}) - -interface TabsProps { - defaultValue: string - children: React.ReactNode - onChange?: (value: string) => void -} -function Tabs({ defaultValue, children, onChange }: TabsProps) { - const [activeTab, setActiveTab] = useState(defaultValue) - - const handleChange = (value: string) => { - setActiveTab(value) - onChange?.(value) - } - - return ( - - {children} - - ) -} - -function TabsList({ - className, - ...props -}: React.ComponentPropsWithoutRef) { - return ( - - ) -} - -interface TabsTriggerProps - extends React.ComponentPropsWithoutRef { - value: string - title: string - textClasses?: string - icon?: LucideIcon -} -function TabsTrigger({ - value, - title, - className, - textClasses, - icon: Icon, - ...props -}: TabsTriggerProps) { - const { activeTab, setActiveTab } = useContext(TabsContext) - - return ( - setActiveTab(value)} - {...props} - > - {Icon && } - - {title} - - - ) -} - -interface TabsContentProps extends React.ComponentPropsWithoutRef { - value: string -} -function TabsContent({ value, className, ...props }: TabsContentProps) { - const { activeTab } = useContext(TabsContext) - - if (value === activeTab) { - return ( - - ) - } - - return null -} - -export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/apps/mobile/components/Toast.tsx b/apps/mobile/components/Toast.tsx deleted file mode 100644 index 3636bc77..00000000 --- a/apps/mobile/components/Toast.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import { createContext, useContext, useEffect, useRef, useState } from 'react' -import { Animated, Text, View } from 'react-native' - -import { cn } from '../lib/utils' - -const toastVariants = { - default: 'bg-foreground', - destructive: 'bg-destructive', - success: 'bg-green-500', - info: 'bg-blue-500', -} - -interface ToastProps { - id: number - message: string - onHide: (id: number) => void - variant?: keyof typeof toastVariants - duration?: number - showProgress?: boolean -} -function Toast({ - id, - message, - onHide, - variant = 'default', - duration = 3000, - showProgress = true, -}: ToastProps) { - const opacity = useRef(new Animated.Value(0)).current - const progress = useRef(new Animated.Value(0)).current - - // biome-ignore lint/correctness/useExhaustiveDependencies: - useEffect(() => { - Animated.sequence([ - Animated.timing(opacity, { - toValue: 1, - duration: 500, - useNativeDriver: true, - }), - Animated.timing(progress, { - toValue: 1, - duration: duration - 1000, - useNativeDriver: false, - }), - Animated.timing(opacity, { - toValue: 0, - duration: 500, - useNativeDriver: true, - }), - ]).start(() => onHide(id)) - }, [duration]) - - return ( - - {message} - {showProgress && ( - - - - )} - - ) -} - -type ToastVariant = keyof typeof toastVariants - -interface ToastMessage { - id: number - text: string - variant: ToastVariant - duration?: number - position?: string - showProgress?: boolean -} -interface ToastContextProps { - toast: ( - message: string, - variant?: keyof typeof toastVariants, - duration?: number, - position?: 'top' | 'bottom', - showProgress?: boolean, - ) => void - removeToast: (id: number) => void -} -const ToastContext = createContext(undefined) - -// TODO: refactor to pass position to Toast instead of ToastProvider -function ToastProvider({ - children, - position = 'top', -}: { - children: React.ReactNode - position?: 'top' | 'bottom' -}) { - const [messages, setMessages] = useState([]) - - const toast: ToastContextProps['toast'] = ( - message: string, - variant: ToastVariant = 'default', - duration: number = 3000, - position: 'top' | 'bottom' = 'top', - showProgress: boolean = true, - ) => { - setMessages((prev) => [ - ...prev, - { - id: Date.now(), - text: message, - variant, - duration, - position, - showProgress, - }, - ]) - } - - const removeToast = (id: number) => { - setMessages((prev) => prev.filter((message) => message.id !== id)) - } - - return ( - - {children} - - {messages.map((message) => ( - - ))} - - - ) -} - -function useToast() { - const context = useContext(ToastContext) - if (!context) { - throw new Error('useToast must be used within ToastProvider') - } - return context -} - -export { ToastProvider, ToastVariant, Toast, toastVariants, useToast } diff --git a/apps/mobile/components/auth/auth-email.tsx b/apps/mobile/components/auth/auth-email.tsx index 99d19887..56d3549f 100644 --- a/apps/mobile/components/auth/auth-email.tsx +++ b/apps/mobile/components/auth/auth-email.tsx @@ -9,9 +9,10 @@ import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import { XCircleIcon } from 'lucide-react-native' import { FormProvider, useForm } from 'react-hook-form' -import { IconButton } from '../IconButton' import { InputField } from '../form-fields/input-field' import { SubmitButton } from '../form-fields/submit-button' +import { Button } from '../ui/button' +import { Text } from '../ui/text' import { type EmailFormValues, type VerifyEmailFormValues, @@ -140,25 +141,26 @@ export function AuthEmail() { onEndEditing={authEmailForm.handleSubmit(onContinue)} rightSection={ verifying && ( - { setVerifying(false) authEmailForm.reset() }} - /> + > + + ) } /> {!verifying && ( + > + {t(i18n)`Continue`} + )} @@ -175,9 +177,10 @@ export function AuthEmail() { onEndEditing={verifyEmailForm.handleSubmit(onVerify)} /> + > + {mode === 'signUp' ? t(i18n)`Sign up` : t(i18n)`Sign in`} + )} diff --git a/apps/mobile/components/auth/auth-social.tsx b/apps/mobile/components/auth/auth-social.tsx index 8b1a77e1..80ca4fd8 100644 --- a/apps/mobile/components/auth/auth-social.tsx +++ b/apps/mobile/components/auth/auth-social.tsx @@ -3,9 +3,10 @@ import { useOAuth } from '@clerk/clerk-expo' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import type { SvgProps } from 'react-native-svg' -import { Button } from '../Button' import { AppleLogo } from '../svg-assets/apple-logo' import { GoogleLogo } from '../svg-assets/google-logo' +import { Button } from '../ui/button' +import { Text } from '../ui/text' type AuthSocialProps = { label: string @@ -38,7 +39,10 @@ export function AuthSocial({ label, icon: Icon, strategy }: AuthSocialProps) { } return ( - ) } diff --git a/apps/mobile/components/common/menu-item.tsx b/apps/mobile/components/common/menu-item.tsx index 81d66113..145d76ca 100644 --- a/apps/mobile/components/common/menu-item.tsx +++ b/apps/mobile/components/common/menu-item.tsx @@ -1,7 +1,8 @@ import { cn } from '@/lib/utils' import { forwardRef } from 'react' -import { Pressable, Text, View } from 'react-native' +import { Pressable, View } from 'react-native' import type { SvgProps } from 'react-native-svg' +import { Text } from '../ui/text' type MenuItemProps = { label: string @@ -25,8 +26,8 @@ export const MenuItem = forwardRef(function ( )} > - {Icon && } - {label} + {Icon && } + {label} {rightSection} diff --git a/apps/mobile/components/common/toolbar.tsx b/apps/mobile/components/common/toolbar.tsx index a0206062..f7dd1bd7 100644 --- a/apps/mobile/components/common/toolbar.tsx +++ b/apps/mobile/components/common/toolbar.tsx @@ -2,21 +2,29 @@ import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import { Link } from 'expo-router' import { PlusIcon, Sparkles } from 'lucide-react-native' -import { View } from 'react-native' -import { IconButton } from '../IconButton' -import { Input } from '../Input' +import { TouchableOpacity, View } from 'react-native' +import { Button } from '../ui/button' +import { Input } from '../ui/input' export function Toolbar() { const { i18n } = useLingui() return ( - } - className="flex-1" - /> + + + + + + - + ) diff --git a/apps/mobile/components/form-fields/input-field.tsx b/apps/mobile/components/form-fields/input-field.tsx index ee5ecec5..aa761d24 100644 --- a/apps/mobile/components/form-fields/input-field.tsx +++ b/apps/mobile/components/form-fields/input-field.tsx @@ -1,15 +1,22 @@ +import { cn } from '@/lib/utils' import { useController } from 'react-hook-form' -import { Text, View } from 'react-native' -import { Input, type InputProps } from '../Input' +import { Text, type TextInputProps, View } from 'react-native' +import { Input } from '../ui/input' +import { Label } from '../ui/label' -type InputFieldProps = InputProps & { +type InputFieldProps = TextInputProps & { name: string label?: string + leftSection?: React.ReactNode + rightSection?: React.ReactNode } export const InputField: React.FC = ({ name, label, + leftSection, + rightSection, + className, ...props }) => { const { @@ -18,8 +25,30 @@ export const InputField: React.FC = ({ } = useController({ name }) return ( - {!!label && {label}} - + {!!label && } + + {leftSection && ( + + {leftSection} + + )} + + {rightSection && ( + + {rightSection} + + )} + {!!fieldState.error && ( {fieldState.error.message} )} diff --git a/apps/mobile/components/form-fields/submit-button.tsx b/apps/mobile/components/form-fields/submit-button.tsx index 50133cfa..bd3dde4a 100644 --- a/apps/mobile/components/form-fields/submit-button.tsx +++ b/apps/mobile/components/form-fields/submit-button.tsx @@ -1,5 +1,5 @@ import { useFormState } from 'react-hook-form' -import { Button, type ButtonProps } from '../Button' +import { Button, type ButtonProps } from '../ui/button' type SubmitButtonProps = ButtonProps diff --git a/apps/mobile/components/home/header.tsx b/apps/mobile/components/home/header.tsx index 2b65d9c7..d5f703b7 100644 --- a/apps/mobile/components/home/header.tsx +++ b/apps/mobile/components/home/header.tsx @@ -3,8 +3,8 @@ import { t } from "@lingui/macro"; import { useLingui } from "@lingui/react"; import { ArrowDownUp, Bell } from "lucide-react-native"; import { Text, TouchableOpacity, View } from "react-native"; -import { Avatar, AvatarFallback, AvatarImage } from "../Avatar"; -import { IconButton } from "../IconButton"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { Button } from "../ui/button"; export function HomeHeader() { const { user } = useUser() @@ -13,7 +13,7 @@ export function HomeHeader() { return ( - + - + ) } \ No newline at end of file diff --git a/apps/mobile/components/primitives/avatar/index.tsx b/apps/mobile/components/primitives/avatar/index.tsx new file mode 100644 index 00000000..b0c7f0c9 --- /dev/null +++ b/apps/mobile/components/primitives/avatar/index.tsx @@ -0,0 +1,119 @@ +import * as Slot from '@/components/primitives/slot' +import type { + ComponentPropsWithAsChild, + SlottableViewProps, + ViewRef, +} from '@/components/primitives/types' +import * as React from 'react' +import { + type ImageErrorEventData, + type ImageLoadEventData, + type NativeSyntheticEvent, + Image as RNImage, + View, +} from 'react-native' +import type { AvatarImageProps, AvatarRootProps } from './types' + +type AvatarState = 'loading' | 'error' | 'loaded' + +interface IRootContext extends AvatarRootProps { + status: AvatarState + setStatus: (status: AvatarState) => void +} + +const RootContext = React.createContext(null) + +const Root = React.forwardRef( + ({ asChild, alt, ...viewProps }, ref) => { + const [status, setStatus] = React.useState('loading') + const Component = asChild ? Slot.View : View + return ( + + + + ) + }, +) + +Root.displayName = 'RootAvatar' + +function useRootContext() { + const context = React.useContext(RootContext) + if (!context) { + throw new Error( + 'Avatar compound components cannot be rendered outside the Avatar component', + ) + } + return context +} + +const Image = React.forwardRef< + React.ElementRef, + Omit, 'alt'> & AvatarImageProps +>( + ( + { + asChild, + onLoad: onLoadProps, + onError: onErrorProps, + onLoadingStatusChange, + ...props + }, + ref, + ) => { + const { alt, setStatus, status } = useRootContext() + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const onLoad = React.useCallback( + (e: NativeSyntheticEvent) => { + setStatus('loaded') + onLoadingStatusChange?.('loaded') + onLoadProps?.(e) + }, + [onLoadProps], + ) + + // biome-ignore lint/correctness/useExhaustiveDependencies: + const onError = React.useCallback( + (e: NativeSyntheticEvent) => { + setStatus('error') + onLoadingStatusChange?.('error') + onErrorProps?.(e) + }, + [onErrorProps], + ) + + if (status === 'error') { + return null + } + + const Component = asChild ? Slot.Image : RNImage + return ( + + ) + }, +) + +Image.displayName = 'ImageAvatar' + +const Fallback = React.forwardRef( + ({ asChild, ...props }, ref) => { + const { alt, status } = useRootContext() + + if (status !== 'error') { + return null + } + const Component = asChild ? Slot.View : View + return + }, +) + +Fallback.displayName = 'FallbackAvatar' + +export { Fallback, Image, Root } diff --git a/apps/mobile/components/primitives/avatar/types.ts b/apps/mobile/components/primitives/avatar/types.ts new file mode 100644 index 00000000..3e47d1ad --- /dev/null +++ b/apps/mobile/components/primitives/avatar/types.ts @@ -0,0 +1,10 @@ +interface AvatarRootProps { + alt: string +} + +interface AvatarImageProps { + children?: React.ReactNode + onLoadingStatusChange?: (status: 'error' | 'loaded') => void +} + +export type { AvatarRootProps, AvatarImageProps } diff --git a/apps/mobile/components/primitives/label/index.ts b/apps/mobile/components/primitives/label/index.ts new file mode 100644 index 00000000..a4b74957 --- /dev/null +++ b/apps/mobile/components/primitives/label/index.ts @@ -0,0 +1 @@ +export * from './label' diff --git a/apps/mobile/components/primitives/label/label.tsx b/apps/mobile/components/primitives/label/label.tsx new file mode 100644 index 00000000..88faef36 --- /dev/null +++ b/apps/mobile/components/primitives/label/label.tsx @@ -0,0 +1,31 @@ +import * as Slot from '@/components/primitives/slot'; +import type { + PressableRef, + SlottablePressableProps, + SlottableTextProps, + TextRef, +} from '@/components/primitives/types'; +import * as React from 'react'; +import { Pressable, Text as RNText } from 'react-native'; +import type { LabelRootProps, LabelTextProps } from './types'; + +const Root = React.forwardRef< + PressableRef, + Omit & LabelRootProps +>(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Pressable : Pressable; + return ; +}); + +Root.displayName = 'RootNativeLabel'; + +const Text = React.forwardRef( + ({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ; + } +); + +Text.displayName = 'TextNativeLabel'; + +export { Root, Text }; \ No newline at end of file diff --git a/apps/mobile/components/primitives/label/label.web.tsx b/apps/mobile/components/primitives/label/label.web.tsx new file mode 100644 index 00000000..5fed2364 --- /dev/null +++ b/apps/mobile/components/primitives/label/label.web.tsx @@ -0,0 +1,36 @@ +import * as Slot from '@/components/primitives/slot'; +import type { + PressableRef, + SlottablePressableProps, + SlottableTextProps, + TextRef, +} from '@/components/primitives/types'; +import * as Label from '@radix-ui/react-label'; +import * as React from 'react'; +import { Text as RNText } from 'react-native'; +import type { LabelRootProps, LabelTextProps } from './types'; + +const Root = React.forwardRef< + PressableRef, + Omit & LabelRootProps +>(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : Slot.View; + return ; +}); + +Root.displayName = 'RootWebLabel'; + +const Text = React.forwardRef( + ({ asChild, nativeID, ...props }, ref) => { + const Component = asChild ? Slot.Text : RNText; + return ( + + + + ); + } +); + +Text.displayName = 'TextWebLabel'; + +export { Root, Text }; \ No newline at end of file diff --git a/apps/mobile/components/primitives/label/types.ts b/apps/mobile/components/primitives/label/types.ts new file mode 100644 index 00000000..ce99341a --- /dev/null +++ b/apps/mobile/components/primitives/label/types.ts @@ -0,0 +1,15 @@ +import type { ViewStyle } from 'react-native' + +interface LabelRootProps { + children: React.ReactNode + style?: ViewStyle +} + +interface LabelTextProps { + /** + * Equivalent to `id` so that the same value can be passed as `aria-labelledby` to the input element. + */ + nativeID: string +} + +export type { LabelRootProps, LabelTextProps } diff --git a/apps/mobile/components/primitives/separator/index.tsx b/apps/mobile/components/primitives/separator/index.tsx new file mode 100644 index 00000000..f1f16ce1 --- /dev/null +++ b/apps/mobile/components/primitives/separator/index.tsx @@ -0,0 +1,23 @@ +import * as Slot from '@/components/primitives/slot'; +import type { SlottableViewProps, ViewRef } from '@/components/primitives/types'; +import * as React from 'react'; +import { View } from 'react-native'; +import type { SeparatorRootProps } from './types'; + +const Root = React.forwardRef( + ({ asChild, decorative, orientation = 'horizontal', ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + ); + } +); + +Root.displayName = 'RootSeparator'; + +export { Root }; diff --git a/apps/mobile/components/primitives/separator/types.ts b/apps/mobile/components/primitives/separator/types.ts new file mode 100644 index 00000000..d7edb53e --- /dev/null +++ b/apps/mobile/components/primitives/separator/types.ts @@ -0,0 +1,6 @@ +interface SeparatorRootProps { + orientation?: 'horizontal' | 'vertical' + decorative?: boolean +} + +export type { SeparatorRootProps } diff --git a/apps/mobile/components/primitives/slot.tsx b/apps/mobile/components/primitives/slot.tsx new file mode 100644 index 00000000..6c6dcf60 --- /dev/null +++ b/apps/mobile/components/primitives/slot.tsx @@ -0,0 +1,218 @@ +import * as React from 'react' +import { + type PressableStateCallbackType, + type Image as RNImage, + type ImageProps as RNImageProps, + type ImageStyle as RNImageStyle, + type Pressable as RNPressable, + type PressableProps as RNPressableProps, + type Text as RNText, + type TextProps as RNTextProps, + type View as RNView, + type ViewProps as RNViewProps, + type StyleProp, + StyleSheet, +} from 'react-native' + +const Pressable = React.forwardRef< + React.ElementRef, + RNPressableProps +>((props, forwardedRef) => { + const { children, ...pressableSlotProps } = props + + if (!React.isValidElement(children)) { + // biome-ignore lint/suspicious/noConsoleLog: + console.log('Slot.Pressable - Invalid asChild element', children) + return null + } + + return React.cloneElement< + React.ComponentPropsWithoutRef, + React.ElementRef + >(isTextChildren(children) ? <> : children, { + ...mergeProps(pressableSlotProps, children.props), + ref: forwardedRef + // biome-ignore lint/suspicious/noExplicitAny: + ? composeRefs(forwardedRef, (children as any).ref) + // biome-ignore lint/suspicious/noExplicitAny: + : (children as any).ref, + }) +}) + +Pressable.displayName = 'SlotPressable' + +const View = React.forwardRef, RNViewProps>( + (props, forwardedRef) => { + const { children, ...viewSlotProps } = props + + if (!React.isValidElement(children)) { + // biome-ignore lint/suspicious/noConsoleLog: + console.log('Slot.View - Invalid asChild element', children) + return null + } + + return React.cloneElement< + React.ComponentPropsWithoutRef, + React.ElementRef + >(isTextChildren(children) ? <> : children, { + ...mergeProps(viewSlotProps, children.props), + ref: forwardedRef + // biome-ignore lint/suspicious/noExplicitAny: + ? composeRefs(forwardedRef, (children as any).ref) + // biome-ignore lint/suspicious/noExplicitAny: + : (children as any).ref, + }) + }, +) + +View.displayName = 'SlotView' + +const Text = React.forwardRef, RNTextProps>( + (props, forwardedRef) => { + const { children, ...textSlotProps } = props + + if (!React.isValidElement(children)) { + // biome-ignore lint/suspicious/noConsoleLog: + console.log('Slot.Text - Invalid asChild element', children) + return null + } + + return React.cloneElement< + React.ComponentPropsWithoutRef, + React.ElementRef + >(isTextChildren(children) ? <> : children, { + ...mergeProps(textSlotProps, children.props), + ref: forwardedRef + // biome-ignore lint/suspicious/noExplicitAny: + ? composeRefs(forwardedRef, (children as any).ref) + // biome-ignore lint/suspicious/noExplicitAny: + : (children as any).ref, + }) + }, +) + +Text.displayName = 'SlotText' + +type ImageSlotProps = RNImageProps & { + children?: React.ReactNode +} + +const Image = React.forwardRef< + React.ElementRef, + ImageSlotProps +>((props, forwardedRef) => { + const { children, ...imageSlotProps } = props + + if (!React.isValidElement(children)) { + // biome-ignore lint/suspicious/noConsoleLog: + console.log('Slot.Image - Invalid asChild element', children) + return null + } + + return React.cloneElement< + React.ComponentPropsWithoutRef, + React.ElementRef + >(isTextChildren(children) ? <> : children, { + ...mergeProps(imageSlotProps, children.props), + ref: forwardedRef + // biome-ignore lint/suspicious/noExplicitAny: + ? composeRefs(forwardedRef, (children as any).ref) + // biome-ignore lint/suspicious/noExplicitAny: + : (children as any).ref, + }) +}) + +Image.displayName = 'SlotImage' + +export { Image, Pressable, Text, View } + +// This project uses code from WorkOS/Radix Primitives. +// The code is licensed under the MIT License. +// https://github.com/radix-ui/primitives/tree/main + +function composeRefs(...refs: (React.Ref | undefined)[]) { + return (node: T) => + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(node) + } else if (ref != null) { + ; (ref as React.MutableRefObject).current = node + } + }) +} + +// biome-ignore lint/suspicious/noExplicitAny: +type AnyProps = Record + +function mergeProps(slotProps: AnyProps, childProps: AnyProps) { + // all child props should override + const overrideProps = { ...childProps } + + for (const propName in childProps) { + const slotPropValue = slotProps[propName] + const childPropValue = childProps[propName] + + const isHandler = /^on[A-Z]/.test(propName) + if (isHandler) { + // if the handler exists on both, we compose them + if (slotPropValue && childPropValue) { + overrideProps[propName] = (...args: unknown[]) => { + childPropValue(...args) + slotPropValue(...args) + } + } + // but if it exists only on the slot, we use only this one + else if (slotPropValue) { + overrideProps[propName] = slotPropValue + } + } + // if it's `style`, we merge them + else if (propName === 'style') { + overrideProps[propName] = combineStyles(slotPropValue, childPropValue) + } else if (propName === 'className') { + overrideProps[propName] = [slotPropValue, childPropValue] + .filter(Boolean) + .join(' ') + } + } + + return { ...slotProps, ...overrideProps } +} + +type PressableStyle = RNPressableProps['style'] +type ImageStyle = StyleProp +type Style = PressableStyle | ImageStyle + +function combineStyles(slotStyle?: Style, childValue?: Style) { + if (typeof slotStyle === 'function' && typeof childValue === 'function') { + return (state: PressableStateCallbackType) => { + return StyleSheet.flatten([slotStyle(state), childValue(state)]) + } + } + if (typeof slotStyle === 'function') { + return (state: PressableStateCallbackType) => { + return childValue + ? StyleSheet.flatten([slotStyle(state), childValue]) + : slotStyle(state) + } + } + if (typeof childValue === 'function') { + return (state: PressableStateCallbackType) => { + return slotStyle + ? StyleSheet.flatten([slotStyle, childValue(state)]) + : childValue(state) + } + } + + return StyleSheet.flatten([slotStyle, childValue].filter(Boolean)) +} + +export function isTextChildren( + children: + | React.ReactNode + | ((state: PressableStateCallbackType) => React.ReactNode), +) { + return Array.isArray(children) + ? children.every((child) => typeof child === 'string') + : typeof children === 'string' +} diff --git a/apps/mobile/components/primitives/tabs/index.ts b/apps/mobile/components/primitives/tabs/index.ts new file mode 100644 index 00000000..3fb765f1 --- /dev/null +++ b/apps/mobile/components/primitives/tabs/index.ts @@ -0,0 +1 @@ +export * from './tabs' diff --git a/apps/mobile/components/primitives/tabs/tabs.tsx b/apps/mobile/components/primitives/tabs/tabs.tsx new file mode 100644 index 00000000..31dad9f3 --- /dev/null +++ b/apps/mobile/components/primitives/tabs/tabs.tsx @@ -0,0 +1,147 @@ +import * as Slot from '@/components/primitives/slot' +import type { + ComponentPropsWithAsChild, + SlottableViewProps, + ViewRef, +} from '@/components/primitives/types' +import * as React from 'react' +import { type GestureResponderEvent, Pressable, View } from 'react-native' +import type { TabsContentProps, TabsRootProps } from './types' + +interface RootContext extends TabsRootProps { + nativeID: string +} + +const TabsContext = React.createContext(null) + +const Root = React.forwardRef( + ( + { + asChild, + value, + onValueChange, + orientation: Orientation, + dir: Dir, + activationMode: ActivationMode, + ...viewProps + }, + ref, + ) => { + const nativeID = React.useId() + const Component = asChild ? Slot.View : View + return ( + + + + ) + }, +) + +Root.displayName = 'RootNativeTabs' + +function useRootContext() { + const context = React.useContext(TabsContext) + if (!context) { + throw new Error( + 'Tabs compound components cannot be rendered outside the Tabs component', + ) + } + return context +} + +const List = React.forwardRef( + ({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View + return + }, +) + +List.displayName = 'ListNativeTabs' + +const TriggerContext = React.createContext<{ value: string } | null>(null) + +const Trigger = React.forwardRef< + React.ElementRef, + ComponentPropsWithAsChild & { + value: string + } +>( + ( + { asChild, onPress: onPressProp, disabled, value: tabValue, ...props }, + ref, + ) => { + const { onValueChange, value: rootValue, nativeID } = useRootContext() + + function onPress(ev: GestureResponderEvent) { + if (disabled) { return } + onValueChange(tabValue) + onPressProp?.(ev) + } + + const Component = asChild ? Slot.Pressable : Pressable + return ( + + + + ) + }, +) + +Trigger.displayName = 'TriggerNativeTabs' + +function useTriggerContext() { + const context = React.useContext(TriggerContext) + if (!context) { + throw new Error( + 'Tabs.Trigger compound components cannot be rendered outside the Tabs.Trigger component', + ) + } + return context +} + +const Content = React.forwardRef< + ViewRef, + SlottableViewProps & TabsContentProps +>(({ asChild, forceMount, value: tabValue, ...props }, ref) => { + const { value: rootValue, nativeID } = useRootContext() + + if (!forceMount) { + if (rootValue !== tabValue) { + return null + } + } + + const Component = asChild ? Slot.View : View + return ( + + ) +}) + +Content.displayName = 'ContentNativeTabs' + +export { Content, List, Root, Trigger, useRootContext, useTriggerContext } diff --git a/apps/mobile/components/primitives/tabs/tabs.web.tsx b/apps/mobile/components/primitives/tabs/tabs.web.tsx new file mode 100644 index 00000000..9dd12606 --- /dev/null +++ b/apps/mobile/components/primitives/tabs/tabs.web.tsx @@ -0,0 +1,97 @@ +import * as Slot from '@/components/primitives/slot'; +import type { ComponentPropsWithAsChild, SlottableViewProps, ViewRef } from '@/components/primitives/types'; +import * as Tabs from '@radix-ui/react-tabs'; +import * as React from 'react'; +import { Pressable, View } from 'react-native'; +import type { TabsContentProps, TabsRootProps } from './types'; + +const TabsContext = React.createContext(null); +const Root = React.forwardRef( + ({ asChild, value, onValueChange, orientation, dir, activationMode, ...viewProps }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + + + + + ); + } +); + +Root.displayName = 'RootWebTabs'; + +function useRootContext() { + const context = React.useContext(TabsContext); + if (!context) { + throw new Error('Tabs compound components cannot be rendered outside the Tabs component'); + } + return context; +} + +const List = React.forwardRef(({ asChild, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + + + ); +}); + +List.displayName = 'ListWebTabs'; + +const TriggerContext = React.createContext<{ value: string } | null>(null); +const Trigger = React.forwardRef< + React.ElementRef, + ComponentPropsWithAsChild & { + value: string; + } +>(({ asChild, value: tabValue, ...props }, ref) => { + const Component = asChild ? Slot.Pressable : Pressable; + return ( + + + + + + ); +}); + +Trigger.displayName = 'TriggerWebTabs'; + +function useTriggerContext() { + const context = React.useContext(TriggerContext); + if (!context) { + throw new Error( + 'Tabs.Trigger compound components cannot be rendered outside the Tabs.Trigger component' + ); + } + return context; +} + +const Content = React.forwardRef( + ({ asChild, forceMount, value, ...props }, ref) => { + const Component = asChild ? Slot.View : View; + return ( + + + + ); + } +); + +Content.displayName = 'ContentWebTabs'; + +export { Content, List, Root, Trigger, useRootContext, useTriggerContext }; \ No newline at end of file diff --git a/apps/mobile/components/primitives/tabs/types.ts b/apps/mobile/components/primitives/tabs/types.ts new file mode 100644 index 00000000..46648fc2 --- /dev/null +++ b/apps/mobile/components/primitives/tabs/types.ts @@ -0,0 +1,24 @@ +import type { ForceMountable } from '@/components/primitives/types' + +interface TabsRootProps { + value: string + onValueChange: (value: string) => void + /** + * Platform: WEB ONLY + */ + orientation?: 'horizontal' | 'vertical' + /** + * Platform: WEB ONLY + */ + dir?: 'ltr' | 'rtl' + /** + * Platform: WEB ONLY + */ + activationMode?: 'automatic' | 'manual' +} + +interface TabsContentProps extends ForceMountable { + value: string +} + +export type { TabsContentProps, TabsRootProps } diff --git a/apps/mobile/components/primitives/types.ts b/apps/mobile/components/primitives/types.ts new file mode 100644 index 00000000..56cd0efb --- /dev/null +++ b/apps/mobile/components/primitives/types.ts @@ -0,0 +1,106 @@ +import type { Pressable, Text, View, ViewStyle } from 'react-native' + +// biome-ignore lint/suspicious/noExplicitAny: +type ComponentPropsWithAsChild> = + React.ComponentPropsWithoutRef & { asChild?: boolean } + +type ViewRef = React.ElementRef +type PressableRef = React.ElementRef +type TextRef = React.ElementRef + +type SlottableViewProps = ComponentPropsWithAsChild +type SlottablePressableProps = ComponentPropsWithAsChild & { + /** + * Platform: WEB ONLY + */ + onKeyDown?: (ev: React.KeyboardEvent) => void + /** + * Platform: WEB ONLY + */ + onKeyUp?: (ev: React.KeyboardEvent) => void +} +type SlottableTextProps = ComponentPropsWithAsChild + +interface Insets { + top?: number + bottom?: number + left?: number + right?: number +} + +type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }> +type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }> + +/** + * Certain props are only available on the native version of the component. + * @docs For the web version, see the Radix documentation https://www.radix-ui.com/primitives + */ +interface PositionedContentProps { + forceMount?: true | undefined + style?: ViewStyle + alignOffset?: number + insets?: Insets + avoidCollisions?: boolean + align?: 'start' | 'center' | 'end' + side?: 'top' | 'bottom' + sideOffset?: number + /** + * Platform: NATIVE ONLY + */ + disablePositioningStyle?: boolean + /** + * Platform: WEB ONLY + */ + loop?: boolean + /** + * Platform: WEB ONLY + */ + onCloseAutoFocus?: (event: Event) => void + /** + * Platform: WEB ONLY + */ + onEscapeKeyDown?: (event: KeyboardEvent) => void + /** + * Platform: WEB ONLY + */ + onPointerDownOutside?: (event: PointerDownOutsideEvent) => void + /** + * Platform: WEB ONLY + */ + onFocusOutside?: (event: FocusOutsideEvent) => void + /** + * Platform: WEB ONLY + */ + onInteractOutside?: ( + event: PointerDownOutsideEvent | FocusOutsideEvent, + ) => void + /** + * Platform: WEB ONLY + */ + collisionBoundary?: Element | null | Array + /** + * Platform: WEB ONLY + */ + sticky?: 'partial' | 'always' + /** + * Platform: WEB ONLY + */ + hideWhenDetached?: boolean +} + +interface ForceMountable { + forceMount?: true | undefined +} + +export type { + ComponentPropsWithAsChild, + ForceMountable, + Insets, + PositionedContentProps, + PressableRef, + SlottablePressableProps, + SlottableTextProps, + SlottableViewProps, + TextRef, + ViewRef, +} diff --git a/apps/mobile/components/ui/avatar.tsx b/apps/mobile/components/ui/avatar.tsx new file mode 100644 index 00000000..564089e3 --- /dev/null +++ b/apps/mobile/components/ui/avatar.tsx @@ -0,0 +1,44 @@ +import * as AvatarPrimitive from '@/components/primitives/avatar'; +import { cn } from '@/lib/utils'; +import * as React from 'react'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarFallback, AvatarImage }; diff --git a/apps/mobile/components/ui/badge.tsx b/apps/mobile/components/ui/badge.tsx new file mode 100644 index 00000000..9b76afdc --- /dev/null +++ b/apps/mobile/components/ui/badge.tsx @@ -0,0 +1,51 @@ +import * as Slot from '@/components/primitives/slot'; +import type { SlottableViewProps } from '@/components/primitives/types'; +import { TextClassContext } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import { type VariantProps, cva } from 'class-variance-authority'; +import { View } from 'react-native'; + +const badgeVariants = cva( + 'web:inline-flex items-center rounded-full border border-border px-2.5 py-0.5 web:transition-colors web:focus:outline-none web:focus:ring-2 web:focus:ring-ring web:focus:ring-offset-2', + { + variants: { + variant: { + default: 'border-transparent bg-primary web:hover:opacity-80 active:opacity-80', + secondary: 'border-transparent bg-secondary web:hover:opacity-80 active:opacity-80', + destructive: 'border-transparent bg-destructive web:hover:opacity-80 active:opacity-80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const badgeTextVariants = cva('text-xs font-semibold ', { + variants: { + variant: { + default: 'text-primary-foreground', + secondary: 'text-secondary-foreground', + destructive: 'text-destructive-foreground', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +type BadgeProps = SlottableViewProps & VariantProps; + +function Badge({ className, variant, asChild, ...props }: BadgeProps) { + const Component = asChild ? Slot.View : View; + return ( + + + + ); +} + +export { Badge, badgeTextVariants, badgeVariants }; +export type { BadgeProps }; diff --git a/apps/mobile/components/ui/button.tsx b/apps/mobile/components/ui/button.tsx new file mode 100644 index 00000000..62006d70 --- /dev/null +++ b/apps/mobile/components/ui/button.tsx @@ -0,0 +1,88 @@ +import { TextClassContext } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import { type VariantProps, cva } from 'class-variance-authority'; +import * as React from 'react'; +import { Pressable } from 'react-native'; + +const buttonVariants = cva( + 'group flex items-center flex-row gap-2 justify-center rounded-md web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2', + { + variants: { + variant: { + default: 'bg-primary web:hover:opacity-90 active:opacity-90', + destructive: 'bg-destructive web:hover:opacity-90 active:opacity-90', + outline: + 'border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent', + secondary: 'bg-secondary web:hover:opacity-80 active:opacity-80', + ghost: 'web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent', + link: 'web:underline-offset-4 web:hover:underline web:focus:underline ', + }, + size: { + default: 'h-10 px-4 py-2 native:h-12 native:px-5 native:py-3', + sm: 'h-9 rounded-md px-3', + lg: 'h-11 rounded-md px-8 native:h-14', + icon: 'h-10 w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +const buttonTextVariants = cva( + 'web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors', + { + variants: { + variant: { + default: 'text-primary-foreground', + destructive: 'text-destructive-foreground', + outline: 'group-active:text-accent-foreground', + secondary: 'text-secondary-foreground group-active:text-secondary-foreground', + ghost: 'group-active:text-accent-foreground', + link: 'text-primary group-active:underline', + }, + size: { + default: '', + sm: '', + lg: 'native:text-lg', + icon: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +type ButtonProps = React.ComponentPropsWithoutRef & + VariantProps; + +const Button = React.forwardRef, ButtonProps>( + ({ className, variant, size, ...props }, ref) => { + return ( + + + + ); + } +); +Button.displayName = 'Button'; + +export { Button, buttonTextVariants, buttonVariants }; +export type { ButtonProps }; \ No newline at end of file diff --git a/apps/mobile/components/ui/input.tsx b/apps/mobile/components/ui/input.tsx new file mode 100644 index 00000000..f44ede87 --- /dev/null +++ b/apps/mobile/components/ui/input.tsx @@ -0,0 +1,25 @@ +import { cn } from '@/lib/utils'; +import * as React from 'react'; +import { TextInput } from 'react-native'; + +const Input = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, placeholderClassName, ...props }, ref) => { + return ( + + ); +}); + +Input.displayName = 'Input'; + +export { Input }; \ No newline at end of file diff --git a/apps/mobile/components/ui/label.tsx b/apps/mobile/components/ui/label.tsx new file mode 100644 index 00000000..82151771 --- /dev/null +++ b/apps/mobile/components/ui/label.tsx @@ -0,0 +1,28 @@ +import * as LabelPrimitive from '@/components/primitives/label'; +import { cn } from '@/lib/utils'; +import * as React from 'react'; + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, onPress, onLongPress, onPressIn, onPressOut, ...props }, ref) => ( + + + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; \ No newline at end of file diff --git a/apps/mobile/components/ui/separator.tsx b/apps/mobile/components/ui/separator.tsx new file mode 100644 index 00000000..3cb783fd --- /dev/null +++ b/apps/mobile/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import * as SeparatorPrimitive from '@/components/primitives/separator'; +import { cn } from '@/lib/utils'; +import * as React from 'react'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/mobile/components/ui/tabs.tsx b/apps/mobile/components/ui/tabs.tsx new file mode 100644 index 00000000..09f80cc6 --- /dev/null +++ b/apps/mobile/components/ui/tabs.tsx @@ -0,0 +1,65 @@ +import * as TabsPrimitive from '@/components/primitives/tabs'; +import { TextClassContext } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; +import * as React from 'react'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { value } = TabsPrimitive.useRootContext(); + return ( + + + + ); +}); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsContent, TabsList, TabsTrigger }; \ No newline at end of file diff --git a/apps/mobile/components/ui/text.tsx b/apps/mobile/components/ui/text.tsx new file mode 100644 index 00000000..e8ce2470 --- /dev/null +++ b/apps/mobile/components/ui/text.tsx @@ -0,0 +1,28 @@ +import * as Slot from '@/components/primitives/slot' +import type { SlottableTextProps, TextRef } from '@/components/primitives/types' +import { cn } from '@/lib/utils' +import * as React from 'react' +import { Text as RNText } from 'react-native' + +const TextClassContext = React.createContext(undefined) + +const Text = React.forwardRef( + ({ className, asChild = false, ...props }, ref) => { + const textClass = React.useContext(TextClassContext) + const Component = asChild ? Slot.Text : RNText + return ( + + ) + }, +) +Text.displayName = 'Text' + +export { Text, TextClassContext } diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 7baecb98..a6734745 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -13,8 +13,8 @@ --popover: 0 0% 100%; --popover-foreground: 222.2 47.4% 11.2%; + --input: 249 6% 90%; --border: 249 6% 90%; - --input: 240 5% 65%; --card: 0 0% 100%; --card-foreground: 222.2 47.4% 11.2%; diff --git a/apps/mobile/hooks/useColorScheme.ts b/apps/mobile/hooks/useColorScheme.ts index b370337a..e83e8226 100644 --- a/apps/mobile/hooks/useColorScheme.ts +++ b/apps/mobile/hooks/useColorScheme.ts @@ -1 +1,13 @@ -export { useColorScheme } from 'react-native' +import { useColorScheme as useNativewindColorScheme } from 'nativewind' + +export function useColorScheme() { + const { colorScheme, setColorScheme, toggleColorScheme } = + useNativewindColorScheme() + + return { + colorScheme: colorScheme ?? 'dark', + isDarkColorScheme: colorScheme === 'dark', + setColorScheme, + toggleColorScheme, + } +} diff --git a/apps/mobile/hooks/useColorScheme.web.ts b/apps/mobile/hooks/useColorScheme.web.ts deleted file mode 100644 index bc612c17..00000000 --- a/apps/mobile/hooks/useColorScheme.web.ts +++ /dev/null @@ -1,8 +0,0 @@ -// NOTE: The default React Native styling doesn't support server rendering. -// Server rendered styles should not change between the first render of the HTML -// and the first render on the client. Typically, web developers will use CSS media queries -// to render different styles on the client and server, these aren't directly supported in React Native -// but can be achieved using a styling library like Nativewind. -export function useColorScheme() { - return 'light' -} diff --git a/apps/mobile/lib/icons/iconWithClassName.ts b/apps/mobile/lib/icons/iconWithClassName.ts new file mode 100644 index 00000000..a24ce6c5 --- /dev/null +++ b/apps/mobile/lib/icons/iconWithClassName.ts @@ -0,0 +1,14 @@ +import type { LucideIcon } from 'lucide-react-native' +import { cssInterop } from 'nativewind' + +export function iconWithClassName(icon: LucideIcon) { + cssInterop(icon, { + className: { + target: 'style', + nativeStyleToProp: { + color: true, + opacity: true, + }, + }, + }) +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 48a0a482..b7c833bb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -25,6 +25,8 @@ "@hookform/resolvers": "^3.6.0", "@lingui/macro": "^4.11.1", "@lingui/react": "^4.11.1", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-tabs": "^1.0.4", "@react-native-async-storage/async-storage": "^1.23.1", "@react-navigation/native": "^6.0.2", "@tanstack/react-query": "^5.40.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af874c75..2bb7eb4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,12 @@ importers: '@lingui/react': specifier: ^4.11.1 version: 4.11.1(react@18.3.1) + '@radix-ui/react-label': + specifier: ^2.0.2 + version: 2.0.2(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@react-native-async-storage/async-storage': specifier: ^1.23.1 version: 1.23.1(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1)) @@ -1728,16 +1734,169 @@ packages: '@prisma/get-platform@5.15.0': resolution: {integrity: sha512-1GULDkW4+/VQb73vihxCBSc4Chc2x88MA+O40tcZFjmBzG4/fF44PaXFxUqKSFltxU9L9GIMLhh0Gfkk/pUbtg==} + '@radix-ui/primitive@1.0.1': + resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} + + '@radix-ui/react-collection@1.0.3': + resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.0.0': resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-compose-refs@1.0.1': + resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.0.1': + resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.0.1': + resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-id@1.0.1': + resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.0.2': + resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.0.1': + resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@1.0.3': + resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.0.4': + resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.0.1': resolution: {integrity: sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw==} peerDependencies: react: ^16.8 || ^17.0 || ^18.0 + '@radix-ui/react-slot@1.0.2': + resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.0.4': + resolution: {integrity: sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.0.1': + resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.0.1': + resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.0.1': + resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@react-native-async-storage/async-storage@1.23.1': resolution: {integrity: sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==} peerDependencies: @@ -8250,17 +8409,159 @@ snapshots: dependencies: '@prisma/debug': 5.15.0 + '@radix-ui/primitive@1.0.1': + dependencies: + '@babel/runtime': 7.24.7 + + '@radix-ui/react-collection@1.0.3(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.79)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.79 + '@types/react-dom': 18.3.0 + '@radix-ui/react-compose-refs@1.0.0(react@18.3.1)': dependencies: '@babel/runtime': 7.24.7 react: 18.3.1 + '@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.79)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.79 + + '@radix-ui/react-context@1.0.1(@types/react@18.2.79)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.79 + + '@radix-ui/react-direction@1.0.1(@types/react@18.2.79)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.79 + + '@radix-ui/react-id@1.0.1(@types/react@18.2.79)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.79)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.79 + + '@radix-ui/react-label@2.0.2(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.79 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.79)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.79 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.79)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.79 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.79)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.79 + '@types/react-dom': 18.3.0 + '@radix-ui/react-slot@1.0.1(react@18.3.1)': dependencies: '@babel/runtime': 7.24.7 '@radix-ui/react-compose-refs': 1.0.0(react@18.3.1) react: 18.3.1 + '@radix-ui/react-slot@1.0.2(@types/react@18.2.79)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.79)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.79 + + '@radix-ui/react-tabs@1.0.4(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.79)(react@18.3.1) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.2.79)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.79)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.2.79 + '@types/react-dom': 18.3.0 + + '@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.79)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.79 + + '@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.79)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.79)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.79 + + '@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.79)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.24.7 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.2.79 + '@react-native-async-storage/async-storage@1.23.1(react-native@0.74.2(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))(@types/react@18.2.79)(react@18.3.1))': dependencies: merge-options: 3.0.4