diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-08-26 15:47:05 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-08-26 13:47:05 +0100 |
| commit | ed86f7ef012fb558fe8a8974e1e162ce75cbfd15 (patch) | |
| tree | a3470b0e1a01aede90b75bc61eeba2545e51fe83 /apps/mobile/components | |
| parent | ec56ea33b5e37d02e87e480da305038a5ce7de49 (diff) | |
| download | karakeep-ed86f7ef012fb558fe8a8974e1e162ce75cbfd15.tar.zst | |
feat(mobile): Retheme the mobile app (#1872)
* Add nativewindui
* migrate to nativewindui text
* Replace buttons with nativewindui buttons
* Use nativewindui search input
* fix the divider color
* More changes
* fix manage tag icon
* fix styling of bookmark card
* fix ios compilation
* fix search clear
* fix tag pill border color
* Store theme setting in app settings
* fix setting color appearance
* fix coloring of search input
* fix following system theme
* add a save button to info
* fix the grey colors on android
* fix icon active tint color
* drop the use of TextField
Diffstat (limited to 'apps/mobile/components')
18 files changed, 1059 insertions, 103 deletions
diff --git a/apps/mobile/components/FullPageError.tsx b/apps/mobile/components/FullPageError.tsx index 57fd62ed..f340d052 100644 --- a/apps/mobile/components/FullPageError.tsx +++ b/apps/mobile/components/FullPageError.tsx @@ -1,4 +1,5 @@ -import { Text, View } from "react-native"; +import { View } from "react-native"; +import { Text } from "@/components/ui/Text"; import { Button } from "./ui/Button"; @@ -16,7 +17,9 @@ export default function FullPageError({ Something Went Wrong </Text> <Text className="text-foreground"> {error}</Text> - <Button onPress={() => onRetry()} label="Retry" /> + <Button onPress={onRetry}> + <Text>Retry</Text> + </Button> </View> </View> ); diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx index 461967b4..e4c2eee8 100644 --- a/apps/mobile/components/bookmarks/BookmarkCard.tsx +++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx @@ -7,7 +7,6 @@ import { Pressable, ScrollView, Share, - Text, View, } from "react-native"; import * as Clipboard from "expo-clipboard"; @@ -15,6 +14,7 @@ import * as FileSystem from "expo-file-system"; import * as Haptics from "expo-haptics"; import { router, useRouter } from "expo-router"; import * as Sharing from "expo-sharing"; +import { Text } from "@/components/ui/Text"; import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; import { MenuView } from "@react-native-menu/menu"; @@ -332,9 +332,7 @@ function LinkCard({ <TagList bookmark={bookmark} /> <Divider orientation="vertical" className="mt-2 h-0.5 w-full" /> <View className="mt-2 flex flex-row justify-between px-2 pb-2"> - <Text className="my-auto line-clamp-1 text-foreground"> - {parsedUrl.host} - </Text> + <Text className="my-auto line-clamp-1">{parsedUrl.host}</Text> <ActionBar bookmark={bookmark} /> </View> </View> @@ -357,7 +355,7 @@ function TextCard({ <View className="flex max-h-96 gap-2 p-2"> <Pressable onPress={onOpenBookmark}> {bookmark.title && ( - <Text className="line-clamp-2 text-xl font-bold text-foreground"> + <Text className="line-clamp-2 text-xl font-bold"> {bookmark.title} </Text> )} @@ -404,9 +402,7 @@ function AssetCard({ <View className="flex gap-2 p-2"> <Pressable onPress={onOpenBookmark}> {title && ( - <Text className="line-clamp-2 text-xl font-bold text-foreground"> - {title} - </Text> + <Text className="line-clamp-2 text-xl font-bold">{title}</Text> )} </Pressable> <TagList bookmark={bookmark} /> @@ -481,9 +477,5 @@ export default function BookmarkCard({ break; } - return ( - <View className="overflow-hidden rounded-xl border-b border-accent bg-background"> - {comp} - </View> - ); + return <View className="overflow-hidden rounded-xl bg-card">{comp}</View>; } diff --git a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx index c4a059cc..730bcd08 100644 --- a/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx +++ b/apps/mobile/components/bookmarks/BookmarkLinkPreview.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, View } from "react-native"; import ImageView from "react-native-image-viewing"; import WebView from "react-native-webview"; import { WebViewSourceUri } from "react-native-webview/lib/WebViewTypes"; +import { Text } from "@/components/ui/Text"; import { useAssetUrl } from "@/lib/hooks"; import { api } from "@/lib/trpc"; -import { useColorScheme } from "nativewind"; +import { useColorScheme } from "@/lib/useColorScheme"; import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; @@ -36,7 +37,7 @@ export function BookmarkLinkReaderPreview({ }: { bookmark: ZBookmark; }) { - const { colorScheme } = useColorScheme(); + const { isDarkColorScheme: isDark } = useColorScheme(); const { data: bookmarkWithContent, @@ -60,8 +61,6 @@ export function BookmarkLinkReaderPreview({ throw new Error("Wrong content type rendered"); } - const isDark = colorScheme === "dark"; - return ( <View className="flex-1 bg-background"> <WebView diff --git a/apps/mobile/components/bookmarks/BookmarkList.tsx b/apps/mobile/components/bookmarks/BookmarkList.tsx index 7be63ed6..adcf12e0 100644 --- a/apps/mobile/components/bookmarks/BookmarkList.tsx +++ b/apps/mobile/components/bookmarks/BookmarkList.tsx @@ -1,6 +1,7 @@ import { useRef } from "react"; -import { ActivityIndicator, Keyboard, Text, View } from "react-native"; +import { ActivityIndicator, Keyboard, View } from "react-native"; import Animated, { LinearTransition } from "react-native-reanimated"; +import { Text } from "@/components/ui/Text"; import { useScrollToTop } from "@react-navigation/native"; import type { ZBookmark } from "@karakeep/shared/types/bookmarks"; @@ -38,7 +39,7 @@ export default function BookmarkList({ renderItem={(b) => <BookmarkCard bookmark={b.item} />} ListEmptyComponent={ <View className="items-center justify-center pt-4"> - <Text className="text-xl text-foreground">No Bookmarks</Text> + <Text variant="title3">No Bookmarks</Text> </View> } data={bookmarks} diff --git a/apps/mobile/components/bookmarks/PDFViewer.tsx b/apps/mobile/components/bookmarks/PDFViewer.tsx index 24b9edfb..c6412431 100644 --- a/apps/mobile/components/bookmarks/PDFViewer.tsx +++ b/apps/mobile/components/bookmarks/PDFViewer.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useMemo, useState } from "react"; -import { ActivityIndicator, StyleSheet, Text, View } from "react-native"; +import { ActivityIndicator, StyleSheet, View } from "react-native"; import ReactNativeBlobUtil from "react-native-blob-util"; import Pdf from "react-native-pdf"; +import { Text } from "@/components/ui/Text"; import { useQuery } from "@tanstack/react-query"; import { useColorScheme } from "nativewind"; diff --git a/apps/mobile/components/bookmarks/TagPill.tsx b/apps/mobile/components/bookmarks/TagPill.tsx index eb9945e5..caf0f636 100644 --- a/apps/mobile/components/bookmarks/TagPill.tsx +++ b/apps/mobile/components/bookmarks/TagPill.tsx @@ -7,7 +7,7 @@ export default function TagPill({ tag }: { tag: ZBookmarkTags }) { return ( <View key={tag.id} - className="rounded-full border border-accent px-2.5 py-0.5 text-xs font-semibold" + className="rounded-full border border-input px-2.5 py-0.5 text-xs font-semibold" > <Link className="text-foreground" href={`dashboard/tags/${tag.id}`}> {tag.name} diff --git a/apps/mobile/components/ui/Button.tsx b/apps/mobile/components/ui/Button.tsx index 0f3b4ab3..312c3129 100644 --- a/apps/mobile/components/ui/Button.tsx +++ b/apps/mobile/components/ui/Button.tsx @@ -1,81 +1,200 @@ import type { VariantProps } from "class-variance-authority"; -import { Text, TouchableOpacity } from "react-native"; +import * as React from "react"; +import { + Platform, + Pressable, + PressableProps, + View, + ViewStyle, +} from "react-native"; +import { TextClassContext } from "@/components/ui/Text"; +import { useColorScheme } from "@/lib/useColorScheme"; import { cn } from "@/lib/utils"; +import { COLORS } from "@/theme/colors"; +import * as Slot from "@rn-primitives/slot"; import { cva } from "class-variance-authority"; -const buttonVariants = cva( - "flex flex-row items-center justify-center rounded-md", - { - variants: { - variant: { - default: "bg-primary", - secondary: "bg-secondary", - destructive: "bg-destructive", - ghost: "bg-slate-700", - link: "text-primary underline-offset-4", - }, - size: { - default: "h-10 px-4", - sm: "h-8 px-2", - lg: "h-12 px-8", - }, +const buttonVariants = cva("flex-row items-center justify-center gap-2", { + variants: { + variant: { + primary: "ios:active:opacity-80 bg-primary", + secondary: + "ios:border-primary ios:active:bg-primary/5 border border-foreground/40", + tonal: + "ios:bg-primary/10 dark:ios:bg-primary/10 ios:active:bg-primary/15 bg-primary/15 dark:bg-primary/30", + plain: "ios:active:opacity-70", + destructive: + "ios:bg-destructive border border-destructive/5 bg-destructive/80", }, - defaultVariants: { - variant: "default", - size: "default", + size: { + none: "", + sm: "rounded-full px-2.5 py-1", + md: "ios:rounded-lg ios:py-1.5 ios:px-3.5 rounded-full px-5 py-2", + lg: "ios:py-2 gap-2 rounded-xl px-5 py-2.5", + icon: "ios:rounded-lg h-10 w-10 rounded-full", }, }, -); + defaultVariants: { + variant: "primary", + size: "md", + }, +}); + +const androidRootVariants = cva("overflow-hidden", { + variants: { + size: { + none: "", + icon: "rounded-full", + sm: "rounded-full", + md: "rounded-full", + lg: "rounded-xl", + }, + }, + defaultVariants: { + size: "md", + }, +}); -const buttonTextVariants = cva("text-center font-medium", { +const buttonTextVariants = cva("font-medium", { variants: { variant: { - default: "text-primary-foreground", - secondary: "text-secondary-foreground", - destructive: "text-destructive-foreground", - ghost: "text-primary-foreground", - link: "text-primary-foreground underline", + primary: "text-white", + secondary: "ios:text-primary text-foreground", + tonal: "ios:text-primary text-foreground", + plain: "text-foreground", + destructive: "text-white", }, size: { - default: "text-base", - sm: "text-sm", - lg: "text-xl", + none: "", + icon: "", + sm: "text-[15px] leading-5", + md: "text-[17px] leading-7", + lg: "text-[17px] leading-7", }, }, defaultVariants: { - variant: "default", - size: "default", + variant: "primary", + size: "md", }, }); -interface ButtonProps - extends React.ComponentPropsWithoutRef<typeof TouchableOpacity>, - VariantProps<typeof buttonVariants> { - label: string; - labelClasses?: string; +function convertToRGBA(rgb: string, opacity: number): string { + const rgbValues = rgb.match(/\d+/g); + if (!rgbValues || rgbValues.length !== 3) { + throw new Error("Invalid RGB color format"); + } + const red = parseInt(rgbValues[0], 10); + const green = parseInt(rgbValues[1], 10); + const blue = parseInt(rgbValues[2], 10); + if (opacity < 0 || opacity > 1) { + throw new Error("Opacity must be a number between 0 and 1"); + } + return `rgba(${red},${green},${blue},${opacity})`; } -function Button({ - label, - labelClasses, - className, - variant, - size, - ...props -}: ButtonProps) { - return ( - <TouchableOpacity - className={cn(buttonVariants({ variant, size, className }))} - {...props} - > - <Text - className={cn( - buttonTextVariants({ variant, size, className: labelClasses }), - )} - > - {label} - </Text> - </TouchableOpacity> - ); + +const ANDROID_RIPPLE = { + dark: { + primary: { + color: convertToRGBA(COLORS.dark.grey3, 0.4), + borderless: false, + }, + secondary: { + color: convertToRGBA(COLORS.dark.grey5, 0.8), + borderless: false, + }, + plain: { color: convertToRGBA(COLORS.dark.grey5, 0.8), borderless: false }, + tonal: { color: convertToRGBA(COLORS.dark.grey5, 0.8), borderless: false }, + destructive: { + color: convertToRGBA(COLORS.dark.destructive, 0.8), + borderless: false, + }, + }, + light: { + primary: { + color: convertToRGBA(COLORS.light.grey4, 0.4), + borderless: false, + }, + secondary: { + color: convertToRGBA(COLORS.light.grey5, 0.4), + borderless: false, + }, + plain: { color: convertToRGBA(COLORS.light.grey5, 0.4), borderless: false }, + tonal: { color: convertToRGBA(COLORS.light.grey6, 0.4), borderless: false }, + destructive: { + color: convertToRGBA(COLORS.light.destructive, 0.4), + borderless: false, + }, + }, +}; + +// Add as class when possible: https://github.com/marklawlor/nativewind/issues/522 +const BORDER_CURVE: ViewStyle = { + borderCurve: "continuous", +}; + +type ButtonVariantProps = Omit< + VariantProps<typeof buttonVariants>, + "variant" +> & { + variant?: Exclude<VariantProps<typeof buttonVariants>["variant"], null>; +}; + +interface AndroidOnlyButtonProps { + /** + * ANDROID ONLY: The class name of root responsible for hidding the ripple overflow. + */ + androidRootClassName?: string; } -export { Button, buttonVariants, buttonTextVariants }; +type ButtonProps = PressableProps & ButtonVariantProps & AndroidOnlyButtonProps; + +const Root = Platform.OS === "android" ? View : Slot.Pressable; + +const Button = React.forwardRef< + React.ElementRef<typeof Pressable>, + ButtonProps +>( + ( + { + className, + variant = "primary", + size, + style = BORDER_CURVE, + androidRootClassName, + ...props + }, + ref, + ) => { + const { colorScheme } = useColorScheme(); + + return ( + <TextClassContext.Provider value={buttonTextVariants({ variant, size })}> + <Root + className={Platform.select({ + ios: androidRootClassName, + default: androidRootVariants({ + size, + className: androidRootClassName, + }), + })} + > + <Pressable + className={cn( + props.disabled && "opacity-50", + buttonVariants({ variant, size, className }), + )} + ref={ref} + style={style} + android_ripple={ANDROID_RIPPLE[colorScheme][variant]} + {...props} + /> + </Root> + </TextClassContext.Provider> + ); + }, +); + +Button.displayName = "Button"; + +export { Button, buttonTextVariants, buttonVariants }; +export type { ButtonProps }; diff --git a/apps/mobile/components/ui/ChevronRight.tsx b/apps/mobile/components/ui/ChevronRight.tsx new file mode 100644 index 00000000..5b9af6e1 --- /dev/null +++ b/apps/mobile/components/ui/ChevronRight.tsx @@ -0,0 +1,11 @@ +import { useColorScheme } from "@/lib/useColorScheme"; +import { ChevronRightIcon } from "lucide-react-native"; + +export default function ChevronRight({ + color, + ...props +}: React.ComponentProps<typeof ChevronRightIcon>) { + const { colors } = useColorScheme(); + + return <ChevronRightIcon color={color ?? colors.grey} {...props} />; +} diff --git a/apps/mobile/components/ui/Divider.tsx b/apps/mobile/components/ui/Divider.tsx index fbc5cf64..bcc6144f 100644 --- a/apps/mobile/components/ui/Divider.tsx +++ b/apps/mobile/components/ui/Divider.tsx @@ -12,7 +12,7 @@ function Divider({ return ( <View className={cn( - "bg-accent", + "bg-slate-400/20 dark:bg-border/50", orientation === "horizontal" ? "h-0.5" : "w-0.5", className, )} diff --git a/apps/mobile/components/ui/Input.tsx b/apps/mobile/components/ui/Input.tsx index 2bd5e190..7f3a48e5 100644 --- a/apps/mobile/components/ui/Input.tsx +++ b/apps/mobile/components/ui/Input.tsx @@ -1,10 +1,9 @@ import type { TextInputProps } from "react-native"; import { forwardRef } from "react"; -import { ActivityIndicator, Text, TextInput, View } from "react-native"; +import { ActivityIndicator, TextInput, View } from "react-native"; +import { Text } from "@/components/ui/Text"; import { cn } from "@/lib/utils"; -import { TailwindResolver } from "../TailwindResolver"; - export interface InputProps extends TextInputProps { label?: string; labelClasses?: string; @@ -22,20 +21,14 @@ export const Input = forwardRef<TextInput, InputProps>( {label && ( <Text className={cn("text-base", labelClasses)}>{label}</Text> )} - <TailwindResolver - className="text-gray-400" - comp={(styles) => ( - <TextInput - ref={ref} - placeholderTextColor={styles?.color?.toString()} - className={cn( - "bg-background text-foreground", - inputClasses, - "rounded-lg border border-input px-4 py-2.5", - )} - {...props} - /> + <TextInput + ref={ref} + className={cn( + "flex h-10 w-full min-w-0 flex-row items-center rounded-md border border-input text-base leading-5 text-foreground shadow-sm shadow-black/5 dark:bg-input/30 sm:h-9", + "rounded-lg border border-input px-4 py-2.5 placeholder:text-muted-foreground/50", + inputClasses, )} + {...props} /> {loading && ( <ActivityIndicator className="absolute bottom-0 right-0 p-2" /> diff --git a/apps/mobile/components/ui/List.tsx b/apps/mobile/components/ui/List.tsx new file mode 100644 index 00000000..52ff5779 --- /dev/null +++ b/apps/mobile/components/ui/List.tsx @@ -0,0 +1,469 @@ +import type { + FlashListProps, + ListRenderItem as FlashListRenderItem, + ListRenderItemInfo, +} from "@shopify/flash-list"; +import * as React from "react"; +import { + Platform, + PressableProps, + StyleProp, + TextStyle, + View, + ViewProps, + ViewStyle, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Button } from "@/components/ui/Button"; +import { Text, TextClassContext } from "@/components/ui/Text"; +import { cn } from "@/lib/utils"; +import { FlashList } from "@shopify/flash-list"; +import { cva } from "class-variance-authority"; +import { cssInterop } from "nativewind"; + +cssInterop(FlashList, { + className: "style", + contentContainerClassName: "contentContainerStyle", +}); + +type ListDataItem = string | { title: string; subTitle?: string }; +type ListVariant = "insets" | "full-width"; + +type ListRef<T extends ListDataItem> = React.Ref<typeof FlashList<T>>; + +type ListRenderItemProps<T extends ListDataItem> = ListRenderItemInfo<T> & { + variant?: ListVariant; + isFirstInSection?: boolean; + isLastInSection?: boolean; + sectionHeaderAsGap?: boolean; +}; + +type ListProps<T extends ListDataItem> = Omit< + FlashListProps<T>, + "renderItem" +> & { + renderItem?: ListRenderItem<T>; + variant?: ListVariant; + sectionHeaderAsGap?: boolean; + rootClassName?: string; + rootStyle?: StyleProp<ViewStyle>; +}; +type ListRenderItem<T extends ListDataItem> = ( + props: ListRenderItemProps<T>, +) => ReturnType<FlashListRenderItem<T>>; + +const rootVariants = cva("min-h-2 flex-1", { + variants: { + variant: { + insets: "ios:px-4", + "full-width": "ios:bg-card ios:dark:bg-background", + }, + sectionHeaderAsGap: { + true: "", + false: "", + }, + }, + compoundVariants: [ + { + variant: "full-width", + sectionHeaderAsGap: true, + className: "bg-card dark:bg-background", + }, + ], + defaultVariants: { + variant: "full-width", + sectionHeaderAsGap: false, + }, +}); + +function ListComponent<T extends ListDataItem>({ + variant = "full-width", + rootClassName, + rootStyle, + contentContainerClassName, + renderItem, + data, + sectionHeaderAsGap = false, + contentInsetAdjustmentBehavior = "automatic", + ...props +}: ListProps<T>) { + const insets = useSafeAreaInsets(); + return ( + <View + className={cn( + rootVariants({ + variant, + sectionHeaderAsGap, + }), + rootClassName, + )} + style={rootStyle} + > + <FlashList + data={data} + contentInsetAdjustmentBehavior={contentInsetAdjustmentBehavior} + renderItem={renderItemWithVariant( + renderItem, + variant, + data, + sectionHeaderAsGap, + )} + contentContainerClassName={cn( + variant === "insets" && + (!data || (typeof data?.[0] !== "string" && "pt-4")), + contentContainerClassName, + )} + contentContainerStyle={{ + paddingBottom: Platform.select({ + ios: + !contentInsetAdjustmentBehavior || + contentInsetAdjustmentBehavior === "never" + ? insets.bottom + 16 + : 0, + default: insets.bottom, + }), + }} + getItemType={getItemType} + showsVerticalScrollIndicator={false} + {...props} + /> + </View> + ); +} + +function getItemType<T>(item: T) { + return typeof item === "string" ? "sectioHeader" : "row"; +} + +function renderItemWithVariant<T extends ListDataItem>( + renderItem: ListRenderItem<T> | null | undefined, + variant: ListVariant, + data: readonly T[] | null | undefined, + sectionHeaderAsGap?: boolean, +) { + return (args: ListRenderItemProps<T>) => { + const previousItem = data?.[args.index - 1]; + const nextItem = data?.[args.index + 1]; + return renderItem + ? renderItem({ + ...args, + variant, + isFirstInSection: !previousItem || typeof previousItem === "string", + isLastInSection: !nextItem || typeof nextItem === "string", + sectionHeaderAsGap, + }) + : null; + }; +} + +const List = React.forwardRef(ListComponent) as <T extends ListDataItem>( + props: ListProps<T> & { ref?: ListRef<T> }, +) => React.ReactElement; + +function isPressable(props: PressableProps) { + return ( + ("onPress" in props && props.onPress) || + ("onLongPress" in props && props.onLongPress) || + ("onPressIn" in props && props.onPressIn) || + ("onPressOut" in props && props.onPressOut) || + ("onLongPress" in props && props.onLongPress) + ); +} + +type ListItemProps<T extends ListDataItem> = PressableProps & + ListRenderItemProps<T> & { + androidRootClassName?: string; + titleClassName?: string; + titleStyle?: StyleProp<TextStyle>; + textNumberOfLines?: number; + subTitleClassName?: string; + subTitleStyle?: StyleProp<TextStyle>; + subTitleNumberOfLines?: number; + textContentClassName?: string; + leftView?: React.ReactNode; + rightView?: React.ReactNode; + removeSeparator?: boolean; + }; +type ListItemRef = React.Ref<View>; + +const itemVariants = cva("ios:gap-0 flex-row gap-0 bg-card", { + variants: { + variant: { + insets: "ios:bg-card bg-card/70", + "full-width": "bg-card dark:bg-background", + }, + sectionHeaderAsGap: { + true: "", + false: "", + }, + isFirstItem: { + true: "", + false: "", + }, + isFirstInSection: { + true: "", + false: "", + }, + removeSeparator: { + true: "", + false: "", + }, + isLastInSection: { + true: "", + false: "", + }, + disabled: { + true: "opacity-70", + false: "opacity-100", + }, + }, + compoundVariants: [ + { + variant: "insets", + sectionHeaderAsGap: true, + className: "ios:dark:bg-card dark:bg-card/70", + }, + { + variant: "insets", + isFirstInSection: true, + className: "ios:rounded-t-[10px]", + }, + { + variant: "insets", + isLastInSection: true, + className: "ios:rounded-b-[10px]", + }, + { + removeSeparator: false, + isLastInSection: true, + className: + "ios:border-b-0 border-b border-border/25 dark:border-border/80", + }, + { + variant: "insets", + isFirstItem: true, + className: "border-t border-border/40", + }, + ], + defaultVariants: { + variant: "insets", + sectionHeaderAsGap: false, + isFirstInSection: false, + isLastInSection: false, + disabled: false, + }, +}); + +function ListItemComponent<T extends ListDataItem>( + { + item, + isFirstInSection, + isLastInSection, + index: _index, + variant, + className, + androidRootClassName, + titleClassName, + titleStyle, + textNumberOfLines, + subTitleStyle, + subTitleClassName, + subTitleNumberOfLines, + textContentClassName, + sectionHeaderAsGap, + removeSeparator = false, + leftView, + rightView, + disabled, + ...props + }: ListItemProps<T>, + ref: ListItemRef, +) { + if (typeof item === "string") { + console.log( + "List.tsx", + "ListItemComponent", + "Invalid item of type 'string' was provided. Use ListSectionHeader instead.", + ); + return null; + } + return ( + <> + <Button + disabled={disabled || !isPressable(props)} + variant="plain" + size="none" + unstable_pressDelay={100} + androidRootClassName={androidRootClassName} + className={itemVariants({ + variant, + sectionHeaderAsGap, + isFirstInSection, + isLastInSection, + disabled, + className, + removeSeparator, + })} + {...props} + ref={ref} + > + <TextClassContext.Provider value="font-normal leading-5"> + {!!leftView && <View>{leftView}</View>} + <View + className={cn( + "h-full flex-1 flex-row", + !item.subTitle ? "ios:py-3 py-[18px]" : "ios:py-2 py-2", + !leftView && "ml-4", + !rightView && "pr-4", + !removeSeparator && + (!isLastInSection || variant === "full-width") && + "ios:border-b ios:border-border/80", + !removeSeparator && + isFirstInSection && + variant === "full-width" && + "ios:border-t ios:border-border/80", + )} + > + <View className={cn("flex-1", textContentClassName)}> + <Text + numberOfLines={textNumberOfLines} + style={titleStyle} + className={titleClassName} + > + {item.title} + </Text> + {!!item.subTitle && ( + <Text + numberOfLines={subTitleNumberOfLines} + variant="subhead" + style={subTitleStyle} + className={cn("text-muted-foreground", subTitleClassName)} + > + {item.subTitle} + </Text> + )} + </View> + {!!rightView && <View>{rightView}</View>} + </View> + </TextClassContext.Provider> + </Button> + {!removeSeparator && Platform.OS !== "ios" && !isLastInSection && ( + <View className={cn(variant === "insets" && "px-4")}> + <View className="h-px bg-border/25 dark:bg-border/80" /> + </View> + )} + </> + ); +} + +const ListItem = React.forwardRef(ListItemComponent) as < + T extends ListDataItem, +>( + props: ListItemProps<T> & { ref?: ListItemRef }, +) => React.ReactElement; + +type ListSectionHeaderProps<T extends ListDataItem> = ViewProps & + ListRenderItemProps<T> & { + textClassName?: string; + }; +type ListSectionHeaderRef = React.Ref<View>; + +function ListSectionHeaderComponent<T extends ListDataItem>( + { + item, + isFirstInSection: _isFirstInSection, + isLastInSection: _isLastInSection, + index: _index, + variant, + className, + textClassName, + sectionHeaderAsGap, + ...props + }: ListSectionHeaderProps<T>, + ref: ListSectionHeaderRef, +) { + if (typeof item !== "string") { + console.log( + "List.tsx", + "ListSectionHeaderComponent", + "Invalid item provided. Expected type 'string'. Use ListItem instead.", + ); + return null; + } + + if (sectionHeaderAsGap) { + return ( + <View + className={cn( + "bg-background", + Platform.OS !== "ios" && + "border-b border-border/25 dark:border-border/80", + className, + )} + {...props} + ref={ref} + > + <View className="h-8" /> + </View> + ); + } + return ( + <View + className={cn( + "ios:pb-1 pb-4 pl-4 pt-4", + Platform.OS !== "ios" && + "border-b border-border/25 dark:border-border/80", + variant === "full-width" + ? "bg-card dark:bg-background" + : "bg-background", + className, + )} + {...props} + ref={ref} + > + <Text + variant={Platform.select({ ios: "footnote", default: "body" })} + className={cn("ios:uppercase ios:text-muted-foreground", textClassName)} + > + {item} + </Text> + </View> + ); +} + +const ListSectionHeader = React.forwardRef(ListSectionHeaderComponent) as < + T extends ListDataItem, +>( + props: ListSectionHeaderProps<T> & { ref?: ListSectionHeaderRef }, +) => React.ReactElement; + +const ESTIMATED_ITEM_HEIGHT = { + titleOnly: Platform.select({ ios: 45, default: 57 }), + withSubTitle: 56, +}; + +function getStickyHeaderIndices<T extends ListDataItem>(data: T[]) { + if (!data) return []; + const indices: number[] = []; + for (let i = 0; i < data.length; i++) { + if (typeof data[i] === "string") { + indices.push(i); + } + } + return indices; +} + +export { + ESTIMATED_ITEM_HEIGHT, + List, + ListItem, + ListSectionHeader, + getStickyHeaderIndices, +}; +export type { + ListDataItem, + ListItemProps, + ListProps, + ListRenderItemInfo, + ListSectionHeaderProps, +}; diff --git a/apps/mobile/components/ui/PageTitle.tsx b/apps/mobile/components/ui/PageTitle.tsx index dc712379..28afa408 100644 --- a/apps/mobile/components/ui/PageTitle.tsx +++ b/apps/mobile/components/ui/PageTitle.tsx @@ -1,4 +1,4 @@ -import { Text } from "react-native"; +import { Text } from "@/components/ui/Text"; import { cx } from "class-variance-authority"; export default function PageTitle({ diff --git a/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx b/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx new file mode 100644 index 00000000..969e48b2 --- /dev/null +++ b/apps/mobile/components/ui/SearchInput/SearchInput.ios.tsx @@ -0,0 +1,187 @@ +import type { + NativeSyntheticEvent, + TextInputFocusEventData, +} from "react-native"; +import * as React from "react"; +import { Pressable, TextInput, View, ViewStyle } from "react-native"; +import Animated, { + measure, + useAnimatedRef, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from "react-native-reanimated"; +import { Text } from "@/components/ui/Text"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { cn } from "@/lib/utils"; +import { useAugmentedRef, useControllableState } from "@rn-primitives/hooks"; +import { Icon } from "@roninoss/icons"; + +import type { SearchInputProps } from "./types"; + +// Add as class when possible: https://github.com/marklawlor/nativewind/issues/522 +const BORDER_CURVE: ViewStyle = { + borderCurve: "continuous", +}; + +const SearchInput = React.forwardRef< + React.ElementRef<typeof TextInput>, + SearchInputProps +>( + ( + { + value: valueProp, + onChangeText: onChangeTextProp, + onFocus: onFocusProp, + placeholder = "Search...", + cancelText = "Cancel", + containerClassName, + iconContainerClassName, + className, + iconColor, + onCancel, + ...props + }, + ref, + ) => { + const { colors } = useColorScheme(); + const inputRef = useAugmentedRef({ ref, methods: { focus, blur, clear } }); + const [showCancel, setShowCancel] = React.useState(false); + const showCancelDerivedValue = useDerivedValue( + () => showCancel, + [showCancel], + ); + const animatedRef = useAnimatedRef(); + + const [value = "", onChangeText] = useControllableState({ + prop: valueProp, + defaultProp: valueProp ?? "", + onChange: onChangeTextProp, + }); + + const rootStyle = useAnimatedStyle(() => { + if (_WORKLET) { + // safely use measure + const measurement = measure(animatedRef); + return { + paddingRight: showCancelDerivedValue.value + ? withTiming(measurement?.width ?? cancelText.length * 11.2) + : withTiming(0), + }; + } + return { + paddingRight: showCancelDerivedValue.value + ? withTiming(cancelText.length * 11.2) + : withTiming(0), + }; + }); + const buttonStyle3 = useAnimatedStyle(() => { + if (_WORKLET) { + // safely use measure + const measurement = measure(animatedRef); + return { + position: "absolute", + right: 0, + opacity: showCancelDerivedValue.value ? withTiming(1) : withTiming(0), + transform: [ + { + translateX: showCancelDerivedValue.value + ? withTiming(0) + : measurement?.width + ? withTiming(measurement.width) + : cancelText.length * 11.2, + }, + ], + }; + } + return { + position: "absolute", + right: 0, + opacity: showCancelDerivedValue.value ? withTiming(1) : withTiming(0), + transform: [ + { + translateX: showCancelDerivedValue.value + ? withTiming(0) + : withTiming(cancelText.length * 11.2), + }, + ], + }; + }); + + function focus() { + inputRef.current?.focus(); + } + + function blur() { + inputRef.current?.blur(); + } + + function clear() { + onChangeText(""); + } + + function onFocus(e: NativeSyntheticEvent<TextInputFocusEventData>) { + setShowCancel(true); + onFocusProp?.(e); + } + + return ( + <Animated.View className="flex-row items-center" style={rootStyle}> + <Animated.View + style={BORDER_CURVE} + className={cn( + "flex-1 flex-row rounded-lg bg-card", + containerClassName, + )} + > + <View + className={cn( + "absolute bottom-0 left-0 top-0 z-50 justify-center pl-1.5", + iconContainerClassName, + )} + > + <Icon color={iconColor ?? colors.grey3} name="magnify" size={22} /> + </View> + <TextInput + ref={inputRef} + placeholder={placeholder} + className={cn( + !showCancel && "active:bg-muted/5 dark:active:bg-muted/20", + "flex-1 rounded-lg py-2 pl-8 pr-1 text-[17px] text-foreground", + className, + )} + value={value} + onChangeText={onChangeText} + onFocus={onFocus} + clearButtonMode="while-editing" + role="searchbox" + {...props} + /> + </Animated.View> + <Animated.View + ref={animatedRef} + style={buttonStyle3} + pointerEvents={!showCancel ? "none" : "auto"} + > + <Pressable + onPress={() => { + onChangeText(""); + inputRef.current?.blur(); + setShowCancel(false); + onCancel?.(); + }} + disabled={!showCancel} + pointerEvents={!showCancel ? "none" : "auto"} + className="flex-1 justify-center active:opacity-50" + > + <Text className="px-2 text-primary">{cancelText}</Text> + </Pressable> + </Animated.View> + </Animated.View> + ); + }, +); + +SearchInput.displayName = "SearchInput"; + +export { SearchInput }; diff --git a/apps/mobile/components/ui/SearchInput/SearchInput.tsx b/apps/mobile/components/ui/SearchInput/SearchInput.tsx new file mode 100644 index 00000000..7e816ab6 --- /dev/null +++ b/apps/mobile/components/ui/SearchInput/SearchInput.tsx @@ -0,0 +1,114 @@ +import * as React from "react"; +import { Pressable, TextInput, View } from "react-native"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { TailwindResolver } from "@/components/TailwindResolver"; +import { Button } from "@/components/ui/Button"; +import { useColorScheme } from "@/lib/useColorScheme"; +import { cn } from "@/lib/utils"; +import { useAugmentedRef, useControllableState } from "@rn-primitives/hooks"; +import { Icon } from "@roninoss/icons"; + +import type { SearchInputProps } from "./types"; + +const SearchInput = React.forwardRef< + React.ElementRef<typeof TextInput>, + SearchInputProps +>( + ( + { + value: valueProp, + onChangeText: onChangeTextProp, + placeholder = "Search...", + containerClassName, + iconContainerClassName, + className, + onCancel, + ...props + }, + ref, + ) => { + const { colors } = useColorScheme(); + const inputRef = useAugmentedRef({ ref, methods: { focus, blur, clear } }); + const [value = "", onChangeText] = useControllableState({ + prop: valueProp, + defaultProp: valueProp ?? "", + onChange: onChangeTextProp, + }); + + function focus() { + inputRef.current?.focus(); + } + + function blur() { + inputRef.current?.blur(); + } + + function clear() { + onCancel?.(); + onChangeText(""); + } + + return ( + <Button + variant="plain" + className={cn( + "android:gap-0 android:h-14 flex-row items-center rounded-full bg-card px-2", + containerClassName, + )} + onPress={focus} + > + <View + className={cn("p-2", iconContainerClassName)} + pointerEvents="none" + > + <TailwindResolver + className="text-muted" + comp={(styles) => ( + <Icon + color={styles?.color?.toString()} + name="magnify" + size={24} + /> + )} + /> + </View> + + <View className="flex-1" pointerEvents="none"> + <TextInput + ref={inputRef} + placeholder={placeholder} + className={cn( + "flex-1 rounded-r-full p-2 text-[17px] text-foreground placeholder:text-muted", + className, + )} + placeholderTextColor={colors.foreground} + value={value} + onChangeText={onChangeText} + role="searchbox" + {...props} + /> + </View> + {!!value && ( + <Animated.View entering={FadeIn} exiting={FadeOut.duration(150)}> + <Pressable className="p-2" onPress={clear}> + <TailwindResolver + className="text-muted" + comp={(styles) => ( + <Icon + name="close" + size={24} + color={styles?.color?.toString()} + /> + )} + /> + </Pressable> + </Animated.View> + )} + </Button> + ); + }, +); + +SearchInput.displayName = "SearchInput"; + +export { SearchInput }; diff --git a/apps/mobile/components/ui/SearchInput/index.ts b/apps/mobile/components/ui/SearchInput/index.ts new file mode 100644 index 00000000..e5150fe3 --- /dev/null +++ b/apps/mobile/components/ui/SearchInput/index.ts @@ -0,0 +1 @@ +export * from "./SearchInput"; diff --git a/apps/mobile/components/ui/SearchInput/types.ts b/apps/mobile/components/ui/SearchInput/types.ts new file mode 100644 index 00000000..e0be8a2c --- /dev/null +++ b/apps/mobile/components/ui/SearchInput/types.ts @@ -0,0 +1,13 @@ +import type { TextInput, TextInputProps } from "react-native"; + +interface SearchInputProps extends TextInputProps { + containerClassName?: string; + iconContainerClassName?: string; + cancelText?: string; + iconColor?: string; + onCancel?: () => void; +} + +type SearchInputRef = TextInput; + +export type { SearchInputProps, SearchInputRef }; diff --git a/apps/mobile/components/ui/Text.tsx b/apps/mobile/components/ui/Text.tsx new file mode 100644 index 00000000..e5590c75 --- /dev/null +++ b/apps/mobile/components/ui/Text.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { Text as RNText } from "react-native"; +import { cn } from "@/lib/utils"; +import { cva, VariantProps } from "class-variance-authority"; + +const textVariants = cva("text-foreground", { + variants: { + variant: { + largeTitle: "text-4xl", + title1: "text-2xl", + title2: "text-[22px] leading-7", + title3: "text-xl", + heading: "text-[17px] font-semibold leading-6", + body: "text-[17px] leading-6", + callout: "text-base", + subhead: "text-[15px] leading-6", + footnote: "text-[13px] leading-5", + caption1: "text-xs", + caption2: "text-[11px] leading-4", + }, + color: { + primary: "", + secondary: "text-secondary-foreground/90", + tertiary: "text-muted-foreground/90", + quarternary: "text-muted-foreground/50", + }, + }, + defaultVariants: { + variant: "body", + color: "primary", + }, +}); + +const TextClassContext = React.createContext<string | undefined>(undefined); + +function Text({ + className, + variant, + color, + ...props +}: React.ComponentPropsWithoutRef<typeof RNText> & + VariantProps<typeof textVariants>) { + const textClassName = React.useContext(TextClassContext); + return ( + <RNText + className={cn(textVariants({ variant, color }), textClassName, className)} + {...props} + /> + ); +} + +export { Text, TextClassContext, textVariants }; diff --git a/apps/mobile/components/ui/Toast.tsx b/apps/mobile/components/ui/Toast.tsx index 7bd2e64d..fd122c25 100644 --- a/apps/mobile/components/ui/Toast.tsx +++ b/apps/mobile/components/ui/Toast.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"; -import { Animated, Text, View } from "react-native"; +import { Animated, View } from "react-native"; +import { Text } from "@/components/ui/Text"; import { cn } from "@/lib/utils"; const toastVariants = { |
