diff --git a/apps/expo-stylesheet/app/(components)/accordion.tsx b/apps/expo-stylesheet/app/(components)/accordion.tsx new file mode 100644 index 00000000..800118bd --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/accordion.tsx @@ -0,0 +1,66 @@ +import { View, StyleSheet } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '~/components/ui/accordion'; +import { Text } from '~/components/ui/text'; + +export default function AccordionScreen() { + const { colors } = useTheme(); + + return ( + + + + + Is it accessible? + + + + Yes. It adheres to the WAI-ARIA design pattern. + + + + + + + What are universal components? + + + + In the world of React Native, universal components are components that work on both + web and native platforms. + + + + + + + Is this component universal? + + + + Yes. Try it out on the web, iOS, and/or Android. + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + accordion: { + width: '100%', + maxWidth: 384, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/alert-dialog.tsx b/apps/expo-stylesheet/app/(components)/alert-dialog.tsx new file mode 100644 index 00000000..e0502a35 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/alert-dialog.tsx @@ -0,0 +1,53 @@ +import { View, StyleSheet } from 'react-native'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '~/components/ui/alert-dialog'; +import { Button } from '~/components/ui/button'; +import { Text } from '~/components/ui/text'; + +export default function AlertDialogScreen() { + return ( + + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete your account and remove + your data from our servers. + + + + + Cancel + + + Continue + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/aspect-ratio.tsx b/apps/expo-stylesheet/app/(components)/aspect-ratio.tsx new file mode 100644 index 00000000..6c93ec1d --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/aspect-ratio.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { AspectRatio } from '~/components/ui/aspect-ratio'; +import { H1 } from '~/components/ui/typography'; + +export default function AspectRatioScreen() { + return ( + + + + +

16:9

+
+
+
+
+ ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + wrapper: { + width: '50%', + }, + innerBox: { + backgroundColor: '#3b82f6', + height: '100%', + width: '100%', + borderRadius: 8, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/avatar.tsx b/apps/expo-stylesheet/app/(components)/avatar.tsx new file mode 100644 index 00000000..a3016633 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/avatar.tsx @@ -0,0 +1,28 @@ +import { View, StyleSheet } from 'react-native'; +import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar'; +import { Text } from '~/components/ui/text'; + +const GITHUB_AVATAR_URI = 'https://github.com/mrzachnugent.png'; + +export default function AvatarScreen() { + return ( + + + + + ZN + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, // p-6 + gap: 48, // gap-12 + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/checkbox.tsx b/apps/expo-stylesheet/app/(components)/checkbox.tsx new file mode 100644 index 00000000..8f981e54 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/checkbox.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { Platform, View, StyleSheet } from 'react-native'; +import { Checkbox } from '~/components/ui/checkbox'; +import { Label } from '~/components/ui/label'; + +export default function CheckboxScreen() { + const [checked, setChecked] = React.useState(false); + + return ( + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 48, + }, + row: { + flexDirection: 'row', + gap: 12, + alignItems: 'center', + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/collapsible.tsx b/apps/expo-stylesheet/app/(components)/collapsible.tsx new file mode 100644 index 00000000..8161e2b7 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/collapsible.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { Platform, Text, View, StyleSheet } from 'react-native'; +import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated'; +import { ChevronsDownUp, ChevronsUpDown } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { Button } from '~/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '~/components/ui/collapsible'; + +export default function CollapsibleScreen() { + const [open, setOpen] = React.useState(false); + const { colors } = useTheme(); + + return ( + + + + + + + @peduarte starred 3 repositories + + + + + + + + @radix-ui/primitives + + + + @radix-ui/react + @stitches/core + + + + + + ); +} + +function CollapsibleItem({ children, delay }: { children: string; delay: number }) { + const { colors } = useTheme(); + + if (Platform.OS === 'web') { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + contentWrapper: { + width: '100%', + maxWidth: 350, + gap: 8, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + paddingHorizontal: 16, + }, + headerText: { + fontSize: Platform.OS === 'web' ? 14 : 16, + fontWeight: '600', + }, + card: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 16, + paddingVertical: 12, + }, + cardText: { + fontSize: Platform.OS === 'web' ? 14 : 16, + lineHeight: 20, + }, + collapsibleContent: { + gap: 8, + }, + srOnly: { + position: 'absolute', + width: 1, + height: 1, + margin: -1, + padding: 0, + borderWidth: 0, + overflow: 'hidden', + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/context-menu.tsx b/apps/expo-stylesheet/app/(components)/context-menu.tsx new file mode 100644 index 00000000..47f9a8b6 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/context-menu.tsx @@ -0,0 +1,161 @@ +import * as React from 'react'; +import { Platform, Pressable, View, StyleSheet } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from '~/components/ui/context-menu'; +import { Text } from '~/components/ui/text'; +import { useTheme } from '@react-navigation/native'; + +export default function ContextScreen() { + const triggerRef = React.useRef>(null); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + const contentInsets = { + top: insets.top, + bottom: insets.bottom, + left: 12, + right: 12, + }; + + const [checkboxValue, setCheckboxValue] = React.useState(false); + const [subCheckboxValue, setSubCheckboxValue] = React.useState(false); + const [radioValue, setRadioValue] = React.useState('pedro'); + + return ( + + { + // Only for Native platforms: open menu programmatically + triggerRef.current?.open(); + }} + /> + + + + {Platform.OS === 'web' ? 'Right click here' : 'Long press here'} + + + + + + Back + ⌘[ + + + Forward + ⌘] + + + Reload + ⌘R + + + + + More Tools + + + + + Save Page As... + ⇧⌘S + + + Create Shortcut... + + + + + Developer Tools + + + + + + + + Show Bookmarks Bar + ⌘⇧B + + + Show Full URLs + + + + People + + + Elmer Fudd + + + Foghorn Leghorn + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + gap: 48, + }, + topRightButton: { + position: 'absolute', + top: 0, + right: 0, + width: 64, + height: 64, + }, + contextTrigger: { + height: 150, + width: '100%', + maxWidth: 300, + marginHorizontal: 'auto', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 6, + borderWidth: 1, + borderStyle: 'dashed', + }, + triggerText: { + fontSize: Platform.OS === 'web' ? 14 : 16, + }, + contextContent: { + width: Platform.OS === 'web' ? 256 : 288, + }, + subContent: { + width: Platform.OS === 'web' ? 192 : 'auto', + marginTop: Platform.OS === 'web' ? 0 : 4, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/dialog.tsx b/apps/expo-stylesheet/app/(components)/dialog.tsx new file mode 100644 index 00000000..0a339170 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/dialog.tsx @@ -0,0 +1,55 @@ +import { ScrollView, StyleSheet } from 'react-native'; +import { Button } from '~/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '~/components/ui/dialog'; +import { Text } from '~/components/ui/text'; + +export default function DialogScreen() { + return ( + + + + + + + + Edit profile + + Make changes to your profile here. Click save when you're done. + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + dialogContent: { + maxWidth: 425, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/dropdown-menu.tsx b/apps/expo-stylesheet/app/(components)/dropdown-menu.tsx new file mode 100644 index 00000000..b8b86c99 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/dropdown-menu.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; +import { Pressable, View, StyleSheet } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Button } from '~/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '~/components/ui/dropdown-menu'; +import { Text } from '~/components/ui/text'; +import { + Cloud, + Github, + LifeBuoy, + LogOut, + Mail, + MessageSquare, + Plus, + PlusCircle, + UserPlus, + Users, +} from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; + +export default function DropdownMenuScreen() { + const triggerRef = React.useRef>(null); + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + + const contentInsets = { + top: insets.top, + bottom: insets.bottom, + left: 12, + right: 12, + }; + + return ( + + { + // open menu programmatically + triggerRef.current?.open(); + }} + /> + + + + + + My Account + + + + + Team + + + + + Invite users + + + + + + Email + + + + Message + + + + + More... + + + + + + + New Team + ⌘+T + + + + + + GitHub + + + + Support + + + + API + + + + + Log out + ⇧⌘Q + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 48, + }, + floatingButton: { + position: 'absolute', + top: 0, + right: 0, + width: 64, + height: 64, + }, + dropdownContent: { + width: 256, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/hover-card.tsx b/apps/expo-stylesheet/app/(components)/hover-card.tsx new file mode 100644 index 00000000..ea345aa0 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/hover-card.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { Pressable, View, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar'; +import { Button } from '~/components/ui/button'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '~/components/ui/hover-card'; +import { Text } from '~/components/ui/text'; +import { CalendarDays } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { type ICustomTheme } from '~/lib/constants'; + +export default function HoverCardScreen() { + const triggerRef = React.useRef>(null); + const insets = useSafeAreaInsets(); + const { colors } = useTheme() as ICustomTheme; + const contentInsets = { + top: insets.top, + bottom: insets.bottom, + left: 12, + right: 12, + }; + + return ( + + { + // open programmatically + triggerRef.current?.open(); + }} + /> + + + + + + + + + + VA + + + + @nextjs + + The React Framework – created and maintained by @vercel. + + + + + Joined December 2021 + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 48, + }, + floatingButton: { + position: 'absolute', + top: 0, + right: 0, + width: 64, + height: 64, + }, + cardContent: { + width: 320, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 16, + }, + userInfo: { + flex: 1, + gap: 4, + }, + username: { + fontSize: 14, + fontWeight: '600', + }, + description: { + fontSize: 14, + lineHeight: 20, + }, + joinedRow: { + flexDirection: 'row', + alignItems: 'center', + paddingTop: 8, + gap: 8, + }, + joinedText: { + fontSize: 12, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/menubar.tsx b/apps/expo-stylesheet/app/(components)/menubar.tsx new file mode 100644 index 00000000..0edfdfd0 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/menubar.tsx @@ -0,0 +1,228 @@ +import { useNavigation } from 'expo-router'; +import * as React from 'react'; +import { Pressable, StyleSheet, View } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + Menubar, + MenubarCheckboxItem, + MenubarContent, + MenubarItem, + MenubarMenu, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSeparator, + MenubarShortcut, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, +} from '~/components/ui/menubar'; +import { Text } from '~/components/ui/text'; + +export default function MenubarScreen() { + const insets = useSafeAreaInsets(); + const contentInsets = { + top: insets.top, + bottom: insets.bottom, + left: 12, + right: 12, + }; + const [value, setValue] = React.useState(); + const [isSubOpen, setIsSubOpen] = React.useState(false); + const [isSubOpen2, setIsSubOpen2] = React.useState(false); + const [isChecked, setIsChecked] = React.useState(false); + const [isChecked2, setIsChecked2] = React.useState(false); + const [radio, setRadio] = React.useState('michael'); + const navigation = useNavigation(); + React.useEffect(() => { + const sub = navigation.addListener('blur', () => { + onValueChange(undefined); + }); + + return sub; + }, []); + + function closeSubs() { + setIsSubOpen(false); + setIsSubOpen2(false); + } + + function onValueChange(val: string | undefined) { + if (typeof val === 'string') { + setValue(val); + return; + } + closeSubs(); + setValue(undefined); + } + + return ( + + {!!value && ( + { + onValueChange(undefined); + }} + style={StyleSheet.absoluteFillObject} + /> + )} + + + + File + + + + New Tab + ⌘T + + + New Window + ⌘N + + + New Incognito Window + + + + + Share + + + + + Email link + + + Messages + + + Notes + + + + + + + Print... + ⌘P + + + + + + Edit + + + + Undo + ⌘Z + + + Redo + ⇧⌘Z + + + + + Find + + + + + Search the web + + + + Find... + + + Find Next + + + Find Previous + + + + + + + Cut + + + Copy + + + Paste + + + + + + View + + + + Always Show Bookmarks Bar + + + Always Show Full URLs + + + + Reload + ⌘R + + + Force Reload + ⇧⌘R + + + + Toggle Fullscreen + + + + Hide Sidebar + + + + + + Profiles + + + + + Andy + + + Michael + + + Creed + + + + + Edit... + + + + Add Profile... + + + + + + ); +} diff --git a/apps/expo-stylesheet/app/(components)/navigation-menu.tsx b/apps/expo-stylesheet/app/(components)/navigation-menu.tsx new file mode 100644 index 00000000..857d2c37 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/navigation-menu.tsx @@ -0,0 +1,237 @@ +import { ViewRef } from '@rn-primitives/types'; +import { useNavigation } from 'expo-router'; +import * as React from 'react'; +import { Platform, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from '~/components/ui/navigation-menu'; +import { Text } from '~/components/ui/text'; +import { Sparkles } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { ICustomTheme } from '~/lib/constants'; + +export default function NavigationMenuScreen() { + const insets = useSafeAreaInsets(); + const { colors } = useTheme() as ICustomTheme; + const contentInsets = { + top: insets.top, + bottom: insets.bottom, + left: 12, + right: 12, + }; + const [value, setValue] = React.useState(); + const navigation = useNavigation(); + const { width } = useWindowDimensions(); + const isMediumOrAboveScreen = width >= 768; + + function closeAll() { + setValue(''); + } + + React.useEffect(() => { + const sub = navigation.addListener('blur', () => { + closeAll(); + }); + + return sub; + }, []); + + return ( + + {Platform.OS !== 'web' && !!value && ( + { + setValue(''); + }} + style={StyleSheet.absoluteFill} + /> + )} + + + + + Getting started + + + + + + + + react-native-reusables + + Universal components that you can copy and paste into your apps. Accessible. + Customizable. Open Source. + + + + + + Re-usable components built using Radix UI on the web and Tailwind CSS. + + + How to install dependencies and structure your app. + + + Styles for headings, paragraphs, lists...etc + + + + + + + Components + + + + {components.map((component) => ( + + {component.description} + + ))} + + + + + + Documentation + + + + + + ); +} + +const components: { title: string; href: string; description: string }[] = [ + { + title: 'Alert Dialog', + href: '/alert-dialog/alert-dialog-universal', + description: + 'A modal dialog that interrupts the user with important content and expects a response.', + }, + { + title: 'Hover Card', + href: '/hover-card/hover-card-universal', + description: 'For sighted users to preview content available behind a link.', + }, + { + title: 'Progress', + href: '/progress/progress-universal', + description: + 'Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.', + }, + { + title: 'Scroll-area', + href: '/scroll-area/scroll-area-universal', + description: + 'Visually or semantically separates content. Typically used to create a scrollable area.', + }, + { + title: 'Tabs', + href: '/tabs/tabs-universal', + description: + 'A set of layered sections of content—known as tab panels—that are displayed one at a time.', + }, + { + title: 'Tooltip', + href: '/tooltip/tooltip-universal', + description: + 'A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.', + }, +]; + +const ListItem = React.forwardRef< + ViewRef, + React.ComponentPropsWithoutRef & { title: string; href: string } +>(({ title, children, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const { width } = useWindowDimensions(); + const isMediumOrAboveScreen = width >= 768; + + return ( + + + {title} + + {children} + + + + ); +}); +ListItem.displayName = 'ListItem'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + paddingHorizontal: 24, + paddingVertical: 10, + gap: 48, + }, + menuContent: { + gap: 12, + padding: 20, + }, + featureCardWrapper: { + flex: 1, + }, + featureCard: { + flexDirection: 'column', + justifyContent: 'flex-end', + borderRadius: 6, + padding: 24, + borderWidth: 1, + }, + featureTitle: { + marginBottom: 8, + marginTop: 16, + fontSize: 20, + fontWeight: '500', + }, + featureDescription: { + fontSize: 14, + lineHeight: 20, + }, + componentsGrid: { + gap: 12, + padding: 16, + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-around', + }, + listItem: { + width: 268, + gap: 4, + borderRadius: 6, + padding: 12, + textDecorationLine: 'none', + }, + listItemTitle: { + fontSize: 14, + lineHeight: 20, + fontWeight: '500', + }, + listItemDescription: { + fontSize: 14, + lineHeight: 20, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/popover.tsx b/apps/expo-stylesheet/app/(components)/popover.tsx new file mode 100644 index 00000000..449d39ff --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/popover.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; +import { Platform, Pressable, View, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Button } from '~/components/ui/button'; +import { Input } from '~/components/ui/input'; +import { Label } from '~/components/ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from '~/components/ui/popover'; +import { Text } from '~/components/ui/text'; +import { useTheme } from '@react-navigation/native'; +import { ICustomTheme } from '~/lib/constants'; + +export default function PopoverScreen() { + const triggerRef = React.useRef>(null); + const insets = useSafeAreaInsets(); + const { colors } = useTheme() as ICustomTheme; + + const contentInsets = { + top: insets.top, + bottom: insets.bottom, + left: 12, + right: 12, + }; + + return ( + + [ + styles.fab, + { backgroundColor: pressed ? colors.accent : 'transparent' }, + ]} + onPress={() => { + // open programmatically + triggerRef.current?.open(); + }} + /> + + + + + + + + Dimensions + + Set the dimensions for the layer. + + + + + + + + + + + + + ); +} + +function LabelledInput({ + id, + label, + autoFocus = false, +}: { + id: string; + label: string; + autoFocus?: boolean; +}) { + const inputRef = React.useRef>(null); + + function onPress() { + inputRef.current?.focus(); + } + + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + fab: { + position: 'absolute', + top: 0, + right: 0, + width: 64, + height: 64, + }, + popoverContent: { + width: 320, + }, + section: { + gap: 16, + }, + title: { + fontWeight: '500', + fontSize: Platform.OS === 'web' ? 16 : 18, + lineHeight: Platform.OS === 'web' ? 20 : 28, + }, + subtitle: { + fontSize: Platform.OS === 'web' ? 14 : 12, + }, + inputGroup: { + gap: 8, + }, + labelledInputRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Platform.OS === 'web' ? 8 : 16, + }, + label: { + width: 96, + }, + input: { + flex: 1, + height: Platform.OS === 'web' ? 32 : 40, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/progress.tsx b/apps/expo-stylesheet/app/(components)/progress.tsx new file mode 100644 index 00000000..dc9b51b0 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/progress.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { View, StyleSheet, Platform } from 'react-native'; +import { Button } from '~/components/ui/button'; +import { Progress } from '~/components/ui/progress'; +import { Text } from '~/components/ui/text'; + +export default function ProgressScreen() { + const [progress, setProgress] = React.useState(13); + + function onPress() { + setProgress(Math.floor(Math.random() * 100)); + } + + return ( + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 48, + }, + inner: { + width: '100%', + gap: 32, + alignItems: 'center', + }, + progress: { + width: Platform.OS === 'web' ? '60%' : '100%', + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/radio-group.tsx b/apps/expo-stylesheet/app/(components)/radio-group.tsx new file mode 100644 index 00000000..22687ccb --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/radio-group.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Label } from '~/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '~/components/ui/radio-group'; + +export default function RadioGroupScreen() { + const [value, setValue] = React.useState('Comfortable'); + + function onLabelPress(label: string) { + return () => { + setValue(label); + }; + } + + return ( + + + + + + + + ); +} + +function RadioGroupItemWithLabel({ + value, + onLabelPress, +}: { + value: string; + onLabelPress: () => void; +}) { + return ( + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + group: { + gap: 12, + }, + itemWithLabel: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/select.tsx b/apps/expo-stylesheet/app/(components)/select.tsx index 318c0430..d8e5d61e 100644 --- a/apps/expo-stylesheet/app/(components)/select.tsx +++ b/apps/expo-stylesheet/app/(components)/select.tsx @@ -1,5 +1,6 @@ +import { useTheme } from '@react-navigation/native'; import * as React from 'react'; -import { Pressable, View } from 'react-native'; +import { Pressable, View, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Select, @@ -10,10 +11,12 @@ import { SelectTrigger, SelectValue, } from '~/components/ui/select'; +import { type ICustomTheme } from '~/lib/constants'; export default function SelectScreen() { const triggerRef = React.useRef>(null); const insets = useSafeAreaInsets(); + const { colors } = useTheme() as ICustomTheme; const contentInsets = { top: insets.top, bottom: insets.bottom, @@ -23,32 +26,20 @@ export default function SelectScreen() { return ( <> - + { - console.log('open'); // open programmatically triggerRef.current?.open(); }} - style={{ - position: 'absolute', - top: 0, - right: 0, - width: 64, - height: 64, - }} /> + + + + + + + + + + + + + {/* Password tab */} + + + + Password + + Change your password here. After saving, you'll be logged out. + + + + + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + padding: 24, + }, + tabs: { + width: '100%', + maxWidth: 400, + marginHorizontal: 'auto', + flexDirection: 'column', + gap: 6, + }, + tabsList: { + flexDirection: 'row', + width: '100%', + }, + cardContent: { + gap: 16, + }, + inputGroup: { + gap: 4, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/toggle-group.tsx b/apps/expo-stylesheet/app/(components)/toggle-group.tsx new file mode 100644 index 00000000..448cb0ba --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/toggle-group.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { ToggleGroup, ToggleGroupIcon, ToggleGroupItem } from '~/components/ui/toggle-group'; +import { Bold, Italic, Underline } from 'lucide-react-native'; + +export default function ToggleGroupScreen() { + const [value, setValue] = React.useState([]); + + return ( + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 48, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/toggle.tsx b/apps/expo-stylesheet/app/(components)/toggle.tsx new file mode 100644 index 00000000..e5fabf53 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/toggle.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Toggle, ToggleIcon } from '~/components/ui/toggle'; +import { Bold } from 'lucide-react-native'; + +export default function ToggleUniversalcreen() { + const [pressed, setPressed] = React.useState(false); + return ( + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 48, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/toolbar.tsx b/apps/expo-stylesheet/app/(components)/toolbar.tsx new file mode 100644 index 00000000..f3d4de2d --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/toolbar.tsx @@ -0,0 +1,152 @@ +import * as Toolbar from '@rn-primitives/toolbar'; +import * as React from 'react'; +import { Text, View, StyleSheet, Platform } from 'react-native'; +import { AlignCenter, AlignLeft, Bold, Italic } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { type ICustomTheme } from '~/lib/constants'; + +export default function ToolbarScreen() { + const [singleValue, setSingleValue] = React.useState(); + const [multipleValue, setMultipleValue] = React.useState([]); + const { colors } = useTheme() as ICustomTheme; + const flattenToolbarRootStyle = StyleSheet.flatten([ + styles.toolbarRoot, + { borderColor: colors.border }, + ]); + const nativeToolbarButtonStyle = ({ pressed }: { pressed: boolean }) => [ + styles.button, + { + backgroundColor: pressed ? colors.accent : colors.buttonSecondary, + }, + ]; + const webToolbarButtonStyle = { + ...styles.button, + backgroundColor: colors.buttonSecondary, + }; + + return ( + + + {/* Bold & Italic Group */} + + + + + + + + + + + + {/* Align Group */} + + + + + + + + + + + + {/* Button aligned right */} + + console.log('Button pressed')} + style={Platform.OS === 'web' ? webToolbarButtonStyle : nativeToolbarButtonStyle} + > + Button + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 48, + }, + toolbarRoot: { + flexDirection: 'row', + gap: 12, + justifyContent: 'center', + borderWidth: 1, + padding: 8, + borderRadius: 8, + }, + group: { + flexDirection: 'row', + gap: 4, + }, + item: { + padding: 8, + borderRadius: 8, + }, + separator: { + height: '100%', + width: 2, + }, + flexEnd: { + flexDirection: 'row', + flex: 1, + justifyContent: 'flex-end', + alignItems: 'center', + }, + button: { + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + }, + buttonText: { + fontSize: 18, + }, +}); diff --git a/apps/expo-stylesheet/app/(components)/tooltip.tsx b/apps/expo-stylesheet/app/(components)/tooltip.tsx new file mode 100644 index 00000000..248d78c8 --- /dev/null +++ b/apps/expo-stylesheet/app/(components)/tooltip.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { Platform, Pressable, View, StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Button } from '~/components/ui/button'; +import { Text } from '~/components/ui/text'; +import { Tooltip, TooltipContent, TooltipTrigger } from '~/components/ui/tooltip'; + +export default function TooltipScreen() { + const triggerRef = React.useRef>(null); + const insets = useSafeAreaInsets(); + const contentInsets = { + top: insets.top, + bottom: insets.bottom, + left: 12, + right: 12, + }; + + return ( + + { + // open programmatically + triggerRef.current?.open(); + }} + /> + + + + + + Add to library + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + floatingButton: { + position: 'absolute', + top: 0, + right: 0, + width: 64, + height: 64, + }, + tooltipText: { + fontSize: Platform.OS === 'web' ? 14 : 16, + }, +}); diff --git a/apps/expo-stylesheet/app/+not-found.tsx b/apps/expo-stylesheet/app/+not-found.tsx index 001d13ff..252b2798 100644 --- a/apps/expo-stylesheet/app/+not-found.tsx +++ b/apps/expo-stylesheet/app/+not-found.tsx @@ -1,15 +1,18 @@ +import { useTheme } from '@react-navigation/native'; import { Link, Stack } from 'expo-router'; import { View, Text } from 'react-native'; export default function NotFoundScreen() { + const { colors } = useTheme(); + return ( <> - This screen doesn't exist. + This screen doesn't exist. - Go to home screen! + Go to home screen! diff --git a/apps/expo-stylesheet/app/_layout.tsx b/apps/expo-stylesheet/app/_layout.tsx index ba3f0f78..3d1040d7 100644 --- a/apps/expo-stylesheet/app/_layout.tsx +++ b/apps/expo-stylesheet/app/_layout.tsx @@ -5,7 +5,9 @@ import { SplashScreen, Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import * as React from 'react'; import { Platform, Text } from 'react-native'; +import { ThemeToggle } from '~/components/ThemeToggle'; import { NAV_THEME } from '~/lib/constants'; +import { useColorScheme } from '~/lib/useColorScheme'; const LIGHT_THEME = { ...DefaultTheme, @@ -25,12 +27,9 @@ export { SplashScreen.preventAutoHideAsync(); export default function RootLayout() { - const { colorScheme, setColorScheme, isDarkColorScheme } = { - colorScheme: 'light', - setColorScheme: (c: any) => {}, - isDarkColorScheme: false, - }; + const { colorScheme, setColorScheme } = useColorScheme(); const [isColorSchemeLoaded, setIsColorSchemeLoaded] = React.useState(false); + const isDarkColorScheme = colorScheme === 'dark'; React.useEffect(() => { (async () => { @@ -68,7 +67,15 @@ export default function RootLayout() { screenOptions={{ headerTitle(props) { return ( - {toOptions(props.children)} + + {toOptions(props.children)} + ); }, }} @@ -77,6 +84,7 @@ export default function RootLayout() { name='index' options={{ title: 'Examples', + headerRight: () => , }} /> diff --git a/apps/expo-stylesheet/app/index.tsx b/apps/expo-stylesheet/app/index.tsx index f70a694e..d1abfa30 100644 --- a/apps/expo-stylesheet/app/index.tsx +++ b/apps/expo-stylesheet/app/index.tsx @@ -1,36 +1,55 @@ import { Link } from 'expo-router'; import * as React from 'react'; -import { FlatList, Pressable, View, Text } from 'react-native'; -import { ChevronRight } from '~/lib/icons/ChevronRight'; +import { FlatList, View, StyleSheet, Platform } from 'react-native'; +import { Button } from '~/components/ui/button'; +import { Input } from '~/components/ui/input'; +import { Text } from '~/components/ui/text'; +import { ChevronRight } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { type ICustomTheme } from '~/lib/constants'; export default function ComponentsScreen() { + const [search, setSearch] = React.useState(''); const ref = React.useRef(null); + const { colors } = useTheme() as ICustomTheme; + + const data = !search + ? COMPONENTS + : COMPONENTS.filter((item) => item.toLowerCase().includes(search.toLowerCase())); return ( - + + + + ( + contentContainerStyle={styles.listContainer} + renderItem={({ item, index }) => ( - - {toOptions(item)} - - + {toOptions(item)} + + )} - ListFooterComponent={} + ListFooterComponent={} /> ); @@ -49,29 +68,69 @@ function toOptions(name: string) { } const COMPONENTS = [ - // 'accordion', - // 'alert-dialog', - // 'aspect-ratio', - // 'avatar', - // 'checkbox', - // 'collapsible', - // 'context-menu', - // 'dialog', - // 'dropdown-menu', - // 'hover-card', - // 'menubar', - // 'navigation-menu', - // 'popover', - // 'progress', - // 'radio-group', + 'accordion', + 'alert-dialog', + 'aspect-ratio', + 'avatar', + 'checkbox', + 'collapsible', + 'context-menu', + 'dialog', + 'dropdown-menu', + 'hover-card', + 'menubar', + 'navigation-menu', + 'popover', + 'progress', + 'radio-group', 'select', - // 'separator', - // 'slider', - // 'switch', - // 'table', - // 'tabs', - // 'toggle', - // 'toggle-group', - // 'toolbar', - // 'tooltip', + 'separator', + 'slider', + 'switch', + 'table', + 'tabs', + 'toggle', + 'toggle-group', + 'toolbar', + 'tooltip', ] as const; + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 16, + }, + searchWrapper: { + paddingVertical: 12, + }, + listContainer: { + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + overflow: 'hidden', + }, + button: { + paddingLeft: 16, + paddingRight: 6, + borderLeftWidth: 1, + borderRightWidth: 1, + borderTopWidth: 1, + borderRadius: 0, + flexDirection: 'row', + justifyContent: 'space-between', + }, + firstButton: { + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + }, + lastButton: { + borderBottomWidth: 1, + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + }, + buttonText: { + fontSize: Platform.OS === 'web' ? 20 : 16, + }, + footerSpacing: { + paddingVertical: 16, + }, +}); diff --git a/apps/expo-stylesheet/babel.config.js b/apps/expo-stylesheet/babel.config.js index 7d507e11..9d89e131 100644 --- a/apps/expo-stylesheet/babel.config.js +++ b/apps/expo-stylesheet/babel.config.js @@ -1,6 +1,6 @@ module.exports = function (api) { api.cache(true); return { - presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'], + presets: ['babel-preset-expo'], }; }; diff --git a/apps/expo-stylesheet/components/ThemeToggle.tsx b/apps/expo-stylesheet/components/ThemeToggle.tsx new file mode 100644 index 00000000..a769d330 --- /dev/null +++ b/apps/expo-stylesheet/components/ThemeToggle.tsx @@ -0,0 +1,44 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Pressable, View, StyleSheet, Platform } from 'react-native'; +import { setAndroidNavigationBar } from '~/lib/android-navigation-bar'; +import { MoonStar, Sun } from 'lucide-react-native'; +import { useColorScheme } from '~/lib/useColorScheme'; +import { useTheme } from '@react-navigation/native'; + +export function ThemeToggle() { + const { colors } = useTheme(); + const { colorScheme, setColorScheme } = useColorScheme(); + const isDarkColorScheme = colorScheme === 'dark'; + + return ( + { + const newTheme = isDarkColorScheme ? 'light' : 'dark'; + setColorScheme(newTheme); + setAndroidNavigationBar(newTheme); + AsyncStorage.setItem('theme', newTheme); + }} + > + {({ pressed }) => ( + + {isDarkColorScheme ? ( + + ) : ( + + )} + + )} + + ); +} + +const styles = StyleSheet.create({ + iconContainer: { + flex: 1, + aspectRatio: 1, + paddingTop: 2, + justifyContent: 'center', + alignItems: 'flex-start', + paddingHorizontal: Platform.OS === 'web' ? 20 : 0, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/accordion.tsx b/apps/expo-stylesheet/components/ui/accordion.tsx new file mode 100644 index 00000000..012e4326 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/accordion.tsx @@ -0,0 +1,156 @@ +import * as AccordionPrimitive from '@rn-primitives/accordion'; +import * as React from 'react'; +import { Platform, Pressable, View, StyleSheet, ViewStyle, StyleProp } from 'react-native'; +import Animated, { + Extrapolation, + FadeIn, + FadeOutUp, + LayoutAnimationConfig, + LinearTransition, + interpolate, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import { useTheme } from '@react-navigation/native'; +import { ChevronDown } from 'lucide-react-native'; +import { TextClassContext } from '~/components/ui/text'; + +const Accordion = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, style, ...props }, ref) => { + return ( + + + {children} + + + ); +}); + +Accordion.displayName = AccordionPrimitive.Root.displayName; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ value, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenStyle = StyleSheet.flatten([ + styles.item, + { borderBottomColor: colors.border }, + style, + ]); + + return ( + + + + ); +}); +AccordionItem.displayName = AccordionPrimitive.Item.displayName; + +const Trigger = Platform.OS === 'web' ? View : Pressable; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { style?: StyleProp } +>(({ children, style, ...props }, ref) => { + const { isExpanded } = AccordionPrimitive.useItemContext(); + const { colors } = useTheme(); + + const progress = useDerivedValue(() => + isExpanded ? withTiming(1, { duration: 250 }) : withTiming(0, { duration: 200 }) + ); + + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${progress.value * 180}deg` }], + opacity: interpolate(progress.value, [0, 1], [1, 0.8], Extrapolation.CLAMP), + })); + + return ( + + + + + <>{children} + + + + + + + + ); +}); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, style, ...props }, ref) => ( + + + {children} + + +)); + +function InnerContent({ + children, + style, +}: { + children: React.ReactNode; + style?: StyleProp; +}) { + if (Platform.OS === 'web') { + return {children}; + } + + return ( + + {children} + + ); +} + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; + +const styles = StyleSheet.create({ + overflowHidden: { + overflow: 'hidden', + }, + item: { + borderBottomWidth: 1, + }, + triggerProvider: { + fontSize: 16, + fontWeight: '500', + }, + triggerHeader: { + display: 'flex', + }, + trigger: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + }, + contentProvider: { + fontSize: 16, + lineHeight: 24, + }, + content: { + overflow: 'hidden', + fontSize: 14, + }, + innerContent: { + paddingBottom: 16, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/alert-dialog.tsx b/apps/expo-stylesheet/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..7bd19136 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/alert-dialog.tsx @@ -0,0 +1,222 @@ +import * as React from 'react'; +import { Platform, StyleSheet, View, ViewStyle, StyleProp } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { buttonVariants, buttonTextVariants } from '~/components/ui/button'; +import * as AlertDialogPrimitive from '@rn-primitives/alert-dialog'; +import { TextClassContext } from '~/components/ui/text'; +import { useTheme } from '@react-navigation/native'; +import { type ICustomTheme } from '~/lib/constants'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlayWeb = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + // const { open } = AlertDialogPrimitive.useRootContext(); + return ; +}); +AlertDialogOverlayWeb.displayName = 'AlertDialogOverlayWeb'; + +const AlertDialogOverlayNative = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, style, ...props }, ref) => { + return ( + + + {children} + + + ); +}); +AlertDialogOverlayNative.displayName = 'AlertDialogOverlayNative'; + +const AlertDialogOverlay = Platform.select({ + web: AlertDialogOverlayWeb, + default: AlertDialogOverlayNative, +}); + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + portalHost?: string; + } +>(({ portalHost, style, ...props }, ref) => { + // const { open } = AlertDialogPrimitive.useRootContext(); + const { colors } = useTheme(); + const flattenContentStyles = StyleSheet.flatten([ + styles.content, + { + backgroundColor: colors.card, + borderColor: colors.border, + shadowColor: colors.text, + }, + style, + ]); + + return ( + + + + + + ); +}); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ style, ...props }: React.ComponentPropsWithoutRef) => ( + +); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ style, ...props }: React.ComponentPropsWithoutRef) => ( + +); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenTitleStyles = StyleSheet.flatten([styles.title, { color: colors.text }, style]); + + return ; +}); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const flattenDescriptionStyles = StyleSheet.flatten([ + styles.description, + { color: colors.mutedText }, + style, + ]); + + return ; +}); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStylesFunc = ({ pressed }: { pressed: boolean }) => [ + buttonVariants({ colors, style }), + pressed && { backgroundColor: colors.borderMedium }, + ]; + const actionBtnStyle = + Platform.OS === 'web' ? buttonVariants({ colors, style }) : nativeStylesFunc; + + return ( + + + + ); +}); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStylesFunc = ({ pressed }: { pressed: boolean }) => [ + buttonVariants({ variant: 'outline', colors, style }), + pressed && { backgroundColor: colors.accent }, + ]; + const cancelBtnStyle = + Platform.OS === 'web' + ? buttonVariants({ variant: 'outline', colors, style }) + : nativeStylesFunc; + + return ( + + + + ); +}); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; + +const styles = StyleSheet.create({ + overlayWeb: { + zIndex: 50, + backgroundColor: 'rgba(0,0,0,0.8)', + justifyContent: 'center', + alignItems: 'center', + padding: 8, + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + overlayNative: { + zIndex: 50, + backgroundColor: 'rgba(0,0,0,0.8)', + justifyContent: 'center', + alignItems: 'center', + padding: 8, + }, + content: { + zIndex: 50, + maxWidth: 512, + gap: 16, + borderWidth: 1, + padding: 24, + borderRadius: 8, + shadowOpacity: 0.1, + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 6, + }, + header: { + flexDirection: 'column', + gap: 8, + }, + footer: { + flexDirection: Platform.OS === 'web' ? 'row' : 'column-reverse', + justifyContent: 'flex-end', + gap: 8, + }, + title: { + fontSize: 18, + lineHeight: 28, + fontWeight: '600', + }, + description: { + fontSize: 14, + lineHeight: 20, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/aspect-ratio.tsx b/apps/expo-stylesheet/components/ui/aspect-ratio.tsx new file mode 100644 index 00000000..d5f98c2e --- /dev/null +++ b/apps/expo-stylesheet/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from '@rn-primitives/aspect-ratio'; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/apps/expo-stylesheet/components/ui/avatar.tsx b/apps/expo-stylesheet/components/ui/avatar.tsx new file mode 100644 index 00000000..ade88e97 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/avatar.tsx @@ -0,0 +1,71 @@ +import * as AvatarPrimitive from '@rn-primitives/avatar'; +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { ICustomTheme } from '~/lib/constants'; + +const AvatarPrimitiveRoot = AvatarPrimitive.Root; +const AvatarPrimitiveImage = AvatarPrimitive.Image; +const AvatarPrimitiveFallback = AvatarPrimitive.Fallback; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + return ; +}); +Avatar.displayName = AvatarPrimitiveRoot.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + return ; +}); +AvatarImage.displayName = AvatarPrimitiveImage.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + + ); +}); +AvatarFallback.displayName = AvatarPrimitiveFallback.displayName; + +export { Avatar, AvatarFallback, AvatarImage }; + +const styles = StyleSheet.create({ + root: { + position: 'relative', + height: 40, // h-10 + width: 40, // w-10 + flexShrink: 0, + overflow: 'hidden', + borderRadius: 50, // rounded-full + }, + image: { + aspectRatio: 1, + height: '100%', + width: '100%', + // resizeMode: 'cover', + }, + fallback: { + height: '100%', + width: '100%', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 50, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/button.tsx b/apps/expo-stylesheet/components/ui/button.tsx new file mode 100644 index 00000000..6d28b7fc --- /dev/null +++ b/apps/expo-stylesheet/components/ui/button.tsx @@ -0,0 +1,190 @@ +import * as React from 'react'; +import { Pressable, StyleProp, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { TextClassContext } from '~/components/ui/text'; +import { ICustomTheme, ICustomThemeColor } from '~/lib/constants'; +import { mergeBaseStyleWithUserStyle } from '~/lib/utils'; + +type Variant = 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; +type Size = 'default' | 'sm' | 'lg' | 'icon'; + +type ButtonProps = React.ComponentPropsWithoutRef & { + variant?: Variant; + size?: Size; + disabled?: boolean; + style?: StyleProp; +}; + +const Button = React.forwardRef, ButtonProps>( + ({ variant = 'default', size = 'default', disabled, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const baseButtonStyles = [ + buttonVariants({ variant, size, colors }), + disabled && styles.disabled, + ]; + + // merging base styles with user passed styles + const mergedStyles = mergeBaseStyleWithUserStyle(baseButtonStyles, style); + + return ( + + [ + ...mergedStyles, + !disabled && (pressed || hovered) && { + backgroundColor: variant === 'default' ? colors.borderMedium : colors.accent, + opacity: 0.7, + }, + ]} + ref={ref} + role='button' + disabled={disabled} + {...props} + /> + + ); + } +); + +Button.displayName = 'Button'; + +export { Button }; +export type { ButtonProps }; +const styles = StyleSheet.create({ + baseButton: { + alignItems: 'center', + justifyContent: 'center', + borderRadius: 6, + }, + baseButtonText: { + fontSize: 16, + fontWeight: '500', + }, + disabled: { + opacity: 0.5, + }, + + sizeDefault: { + height: Platform.OS === 'web' ? 40 : 44, + paddingHorizontal: 16, + paddingVertical: 8, + }, + sizeSm: { + height: 36, + borderRadius: 6, + paddingHorizontal: 12, + }, + sizeLg: { + height: Platform.OS === 'web' ? 44 : 50, + paddingHorizontal: 32, + borderRadius: 6, + }, + sizeIcon: { + height: 40, + width: 40, + }, +}); + +export const buttonVariants = ({ + variant = 'default', + size = 'default', + colors, + style, +}: { + variant?: Variant; + size?: Size; + colors: ICustomThemeColor; + style?: StyleProp; +}): ViewStyle => { + let variantStyle: ViewStyle = {}; + let sizeStyle: ViewStyle = {}; + + // === VARIANT STYLES === + if (variant === 'default') { + variantStyle = { backgroundColor: colors.buttonPrimary }; + } else if (variant === 'destructive') { + variantStyle = { backgroundColor: colors.notification }; + } else if (variant === 'outline') { + variantStyle = { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.buttonOutline, + }; + } else if (variant === 'secondary') { + variantStyle = { backgroundColor: colors.buttonSecondary }; + } else if (variant === 'ghost') { + variantStyle = { backgroundColor: colors.buttonGhost }; + } else if (variant === 'link') { + variantStyle = { backgroundColor: colors.buttonLink }; + } + + // === SIZE STYLES === + if (size === 'default') { + sizeStyle = styles.sizeDefault; + } else if (size === 'sm') { + sizeStyle = styles.sizeSm; + } else if (size === 'lg') { + sizeStyle = styles.sizeLg; + } else if (size === 'icon') { + sizeStyle = styles.sizeIcon; + } + + return StyleSheet.flatten([ + { + ...styles.baseButton, + ...variantStyle, + ...sizeStyle, + }, + style, + ]); +}; + +export const buttonTextVariants = ({ + variant = 'default', + size = 'default', + colors, + style, +}: { + variant?: Variant; + size?: Size; + colors: ICustomThemeColor; + style?: StyleProp; +}): TextStyle => { + let variantStyle: TextStyle = {}; + let sizeStyle: TextStyle = {}; + + // === VARIANT STYLES === + if (variant === 'default') { + variantStyle = { color: colors.buttonPrimaryText }; + } else if (variant === 'destructive') { + variantStyle = { color: '#fff' }; + } else if (variant === 'outline') { + variantStyle = { color: colors.buttonOutlineText }; + } else if (variant === 'secondary') { + variantStyle = { color: colors.buttonSecondaryText }; + } else if (variant === 'ghost') { + variantStyle = { color: colors.buttonGhostText }; + } else if (variant === 'link') { + variantStyle = { color: colors.buttonLinkText }; + } + + // === SIZE STYLES === + if (size === 'default') { + sizeStyle = { fontSize: 14 }; + } else if (size === 'sm') { + sizeStyle = { fontSize: 14 }; + } else if (size === 'lg') { + sizeStyle = { fontSize: Platform.OS === 'web' ? 14 : 16 }; + } else if (size === 'icon') { + sizeStyle = { fontSize: 16 }; + } + + return StyleSheet.flatten([ + { + ...styles.baseButtonText, + ...variantStyle, + ...sizeStyle, + }, + style, + ]); +}; diff --git a/apps/expo-stylesheet/components/ui/card.tsx b/apps/expo-stylesheet/components/ui/card.tsx new file mode 100644 index 00000000..c27f5221 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/card.tsx @@ -0,0 +1,121 @@ +import * as React from 'react'; +import { Text, View, StyleSheet, Platform } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { TextClassContext } from '~/components/ui/text'; +import type { TextRef, ViewRef } from '@rn-primitives/types'; +import { type ICustomTheme } from '~/lib/constants'; + +const Card = React.forwardRef>( + ({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + ); + } +); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef>( + ({ style, ...props }, ref) => +); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef>( + ({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + ); + } +); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef>( + ({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + + ); + } +); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef>( + ({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + + + ); + } +); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef>( + ({ style, ...props }, ref) => +); +CardFooter.displayName = 'CardFooter'; + +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; + +const styles = StyleSheet.create({ + card: { + borderRadius: 8, + borderWidth: 1, + shadowOpacity: 0.1, + shadowOffset: { width: 0, height: 1 }, + shadowRadius: 2, + }, + cardHeader: { + flexDirection: 'column', + gap: 6, + padding: 24, + }, + cardTitle: { + fontSize: 24, + fontWeight: '600', + lineHeight: 24, + letterSpacing: -0.25, + }, + cardDescription: { + fontSize: Platform.OS === 'web' ? 14 : 12, + lineHeight: Platform.OS === 'web' ? 20 : 16, + }, + cardContent: { + padding: 24, + paddingTop: 0, + }, + cardFooter: { + flexDirection: 'row', + alignItems: 'center', + padding: 24, + paddingTop: 0, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/checkbox.tsx b/apps/expo-stylesheet/components/ui/checkbox.tsx new file mode 100644 index 00000000..f86ab34b --- /dev/null +++ b/apps/expo-stylesheet/components/ui/checkbox.tsx @@ -0,0 +1,62 @@ +import * as CheckboxPrimitive from '@rn-primitives/checkbox'; +import * as React from 'react'; +import { Platform, StyleProp, StyleSheet, ViewStyle } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { Check } from 'lucide-react-native'; +import { ICustomTheme } from '~/lib/constants'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, checked, disabled, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const flattenStyle = StyleSheet.flatten([ + styles.base, + { + borderColor: colors.primary, + backgroundColor: checked ? colors.primary : colors.background, + opacity: disabled ? 0.5 : 1, + }, + style, + ]); + + return ( + + + + + + ); +}); + +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; + +const styles = StyleSheet.create({ + base: { + height: 16, + width: 16, + flexShrink: 0, + borderRadius: 2, + borderWidth: 1, + }, + indicator: { + justifyContent: 'center', + alignItems: 'center', + height: '100%', + width: '100%', + }, +}); diff --git a/apps/expo-stylesheet/components/ui/collapsible.tsx b/apps/expo-stylesheet/components/ui/collapsible.tsx new file mode 100644 index 00000000..a2d66dd5 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from '@rn-primitives/collapsible'; + +const Collapsible = CollapsiblePrimitive.Root; + +const CollapsibleTrigger = CollapsiblePrimitive.Trigger; + +const CollapsibleContent = CollapsiblePrimitive.Content; + +export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/apps/expo-stylesheet/components/ui/context-menu.tsx b/apps/expo-stylesheet/components/ui/context-menu.tsx new file mode 100644 index 00000000..3eb5d7af --- /dev/null +++ b/apps/expo-stylesheet/components/ui/context-menu.tsx @@ -0,0 +1,379 @@ +import * as ContextMenuPrimitive from '@rn-primitives/context-menu'; +import * as React from 'react'; +import { Platform, StyleProp, StyleSheet, Text, View, ViewStyle } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { Check, ChevronDown, ChevronRight, ChevronUp } from 'lucide-react-native'; +import { TextClassContext } from '~/components/ui/text'; +import { type ICustomTheme } from '~/lib/constants'; + +const ContextMenu = ContextMenuPrimitive.Root; + +// const ContextMenuTrigger = ContextMenuPrimitive.Trigger; +const ContextMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, ...props }, ref) => { + const flattenStyle: ViewStyle = StyleSheet.flatten([{ display: 'flex' }, style]); + + return ; +}); +ContextMenuTrigger.displayName = ContextMenuPrimitive.Trigger.displayName; + +const ContextMenuGroup = ContextMenuPrimitive.Group; +const ContextMenuSub = ContextMenuPrimitive.Sub; +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + style?: StyleProp; + } +>(({ inset, children, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const { open } = ContextMenuPrimitive.useSubContext(); + const Icon = Platform.OS === 'web' ? ChevronRight : open ? ChevronUp : ChevronDown; + const nativeStyles = ({ pressed }: { pressed: boolean }) => [ + styles.itemBase, + open && { backgroundColor: colors.card }, + inset && { paddingLeft: 32 }, + style, + pressed && { backgroundColor: colors.accent }, + ]; + const webStyles = StyleSheet.flatten([ + styles.itemBase, + open && { backgroundColor: colors.card }, + inset && { paddingLeft: 32 }, + style, + ]); + + return ( + + + <>{children} + + + + ); +}); +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + ); +}); +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + overlayStyle?: StyleProp; + portalHost?: string; + } +>(({ overlayStyle, portalHost, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenContentStyle = StyleSheet.flatten([ + styles.content, + { + borderColor: colors.border, + backgroundColor: colors.card, + }, + style, + ]); + + return ( + + + + + + ); +}); +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + style?: StyleProp; + } +>(({ inset, disabled, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStyles = ({ pressed }: { pressed: boolean }) => [ + styles.itemBase, + inset && { paddingLeft: 32 }, + disabled && { opacity: 0.5 }, + style, + pressed && { backgroundColor: colors.accent }, + ]; + const webStyles = StyleSheet.flatten([ + styles.itemBase, + inset && { paddingLeft: 32 }, + disabled && { opacity: 0.5 }, + style, + ]); + + return ( + + + + ); +}); +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ disabled, children, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStyles = ({ pressed }: { pressed: boolean }) => [ + styles.checkboxItem, + disabled && { opacity: 0.5 }, + style, + { + backgroundColor: pressed ? colors.accent : 'transparent', + }, + ]; + const webStyles = StyleSheet.flatten([styles.checkboxItem, disabled && { opacity: 0.5 }, style]); + + return ( + + + + + + + + <>{children} + + + ); +}); +ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName; + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ disabled, children, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStyles = ({ pressed }: { pressed: boolean }) => [ + styles.radioItem, + disabled && { opacity: 0.5 }, + style, + { + backgroundColor: pressed ? colors.accent : 'transparent', + }, + ]; + const webStyles = StyleSheet.flatten([styles.radioItem, disabled && { opacity: 0.5 }, style]); + + return ( + + + + + + + + <>{children} + + + ); +}); +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ inset, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenStyle = StyleSheet.flatten([ + styles.label, + { color: colors.text }, + inset && { paddingLeft: 32 }, + style, + ]); + + return ; +}); +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenStyle = StyleSheet.flatten([ + styles.separator, + { backgroundColor: colors.border }, + style, + ]); + + return ; +}); +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; + +const ContextMenuShortcut = ({ style, ...props }: React.ComponentPropsWithoutRef) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + + ); +}; +ContextMenuShortcut.displayName = 'ContextMenuShortcut'; + +export { + ContextMenu, + ContextMenuCheckboxItem, + ContextMenuContent, + ContextMenuTrigger, + ContextMenuGroup, + ContextMenuItem, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, +}; + +const styles = StyleSheet.create({ + content: { + zIndex: 50, + minWidth: 128, + overflow: 'hidden', + borderRadius: 6, + borderWidth: 1, + marginTop: 4, + padding: 4, + shadowColor: '#000', + shadowOpacity: 0.05, + shadowOffset: { width: 0, height: 1 }, + shadowRadius: 3, + }, + itemBase: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 8, + paddingVertical: 6, + borderRadius: 2, + }, + checkboxItem: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + borderRadius: 2, + paddingVertical: 6, + paddingLeft: 32, + paddingRight: 8, + }, + radioItem: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + borderRadius: 2, + paddingVertical: 6, + paddingLeft: 32, + paddingRight: 8, + }, + indicatorBox: { + position: 'absolute', + left: 8, + height: 14, + width: 14, + alignItems: 'center', + justifyContent: 'center', + }, + label: { + paddingHorizontal: 8, + paddingVertical: 6, + fontSize: 14, + fontWeight: '600', + }, + separator: { + marginHorizontal: -4, + marginVertical: 4, + height: StyleSheet.hairlineWidth, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/dialog.tsx b/apps/expo-stylesheet/components/ui/dialog.tsx new file mode 100644 index 00000000..ab65cba7 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/dialog.tsx @@ -0,0 +1,192 @@ +import * as DialogPrimitive from '@rn-primitives/dialog'; +import * as React from 'react'; +import { Platform, StyleSheet, View, ViewStyle, StyleProp } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { X } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { ICustomTheme } from '~/lib/constants'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlayWeb = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + return ( + ]} + {...props} + /> + ); +}); + +DialogOverlayWeb.displayName = 'DialogOverlayWeb'; + +const DialogOverlayNative = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, style, ...props }, ref) => { + return ( + ]} + {...props} + > + + <>{children} + + + ); +}); + +DialogOverlayNative.displayName = 'DialogOverlayNative'; + +const DialogOverlay = Platform.select({ + web: DialogOverlayWeb, + default: DialogOverlayNative, +}); + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + portalHost?: string; + } +>(({ children, portalHost, style, ...props }, ref) => { + const { open } = DialogPrimitive.useRootContext(); + const { colors } = useTheme(); + + return ( + + + + {children} + + + + + + + ); +}); + +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ style, ...props }: React.ComponentPropsWithoutRef) => ( + +); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ style, ...props }: React.ComponentPropsWithoutRef) => ( + +); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenStyle = StyleSheet.flatten([styles.title, { color: colors.text }, style]); + + return ; +}); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const flattenStyle = StyleSheet.flatten([styles.description, { color: colors.mutedText }, style]); + + return ; +}); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; + +const styles = StyleSheet.create({ + overlayWeb: { + backgroundColor: 'rgba(0,0,0,0.8)', + justifyContent: 'center', + alignItems: 'center', + padding: 8, + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + overlayNative: { + backgroundColor: 'rgba(0,0,0,0.8)', + justifyContent: 'center', + alignItems: 'center', + padding: 8, + }, + content: { + maxWidth: 512, + gap: 16, + borderWidth: 1, + padding: 24, + borderRadius: 8, + shadowOpacity: 0.2, + shadowRadius: 8, + shadowOffset: { width: 0, height: 4 }, + }, + closeButton: { + position: 'absolute', + right: 16, + top: 16, + padding: 2, + borderRadius: 2, + opacity: 0.7, + }, + header: { + flexDirection: 'column', + gap: 6, + }, + footer: { + flexDirection: Platform.OS === 'web' ? 'row' : 'column-reverse', + justifyContent: Platform.OS === 'web' ? 'flex-end' : 'flex-start', + gap: 8, + }, + title: { + fontSize: 18, + fontWeight: '600', + letterSpacing: -0.25, + }, + description: { + fontSize: 14, + lineHeight: 20, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/dropdown-menu.tsx b/apps/expo-stylesheet/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..86c33de2 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/dropdown-menu.tsx @@ -0,0 +1,355 @@ +import * as DropdownMenuPrimitive from '@rn-primitives/dropdown-menu'; +import * as React from 'react'; +import { Platform, StyleProp, StyleSheet, Text, TextStyle, View, ViewStyle } from 'react-native'; +import { Check, ChevronDown, ChevronRight, ChevronUp } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { TextClassContext } from '~/components/ui/text'; +import { ICustomTheme } from '~/lib/constants'; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + style?: StyleProp; + } +>(({ inset, children, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const { open } = DropdownMenuPrimitive.useSubContext(); + const Icon = Platform.OS === 'web' ? ChevronRight : open ? ChevronUp : ChevronDown; + const nativeStyle = ({ pressed }: { pressed: boolean }) => [ + styles.item, + { backgroundColor: open ? colors.accent : 'transparent' }, + inset && { paddingLeft: 32 }, + pressed && { backgroundColor: colors.accent }, + style, + ]; + const webStyle = StyleSheet.flatten([ + styles.item, + { backgroundColor: open ? colors.accent : 'transparent' }, + inset && { paddingLeft: 32 }, + style, + ]); + + return ( + + + <>{children} + + + + ); +}); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + ); +}); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + overlayStyle?: StyleProp; + portalHost?: string; + } +>(({ overlayStyle, portalHost, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenContentStyles = StyleSheet.flatten([ + styles.content, + { + borderColor: colors.border, + backgroundColor: colors.card, + shadowColor: colors.text, + }, + style, + ]); + + return ( + + + + + + ); +}); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + style?: StyleProp; + } +>(({ inset, style, disabled, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStyle = ({ pressed }: { pressed: boolean }) => [ + styles.item, + inset && { paddingLeft: 32 }, + disabled && { opacity: 0.5 }, + pressed && { backgroundColor: colors.accent }, + style, + ]; + const webStyle = StyleSheet.flatten([ + styles.item, + inset && { paddingLeft: 32 }, + disabled && { opacity: 0.5 }, + style, + ]); + + return ( + + + + ); +}); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ children, checked, style, disabled, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + [ + styles.checkboxItem, + disabled && { opacity: 0.5 }, + pressed && { backgroundColor: colors.accent }, + style, + ]} + {...props} + > + + + + + + <>{children} + + ); +}); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ children, style, disabled, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + [ + styles.radioItem, + disabled && { opacity: 0.5 }, + pressed && { backgroundColor: colors.accent }, + style, + ]} + {...props} + > + + + + + + <>{children} + + ); +}); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ inset, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenStyle: TextStyle = StyleSheet.flatten([ + { + paddingHorizontal: 8, + paddingVertical: 6, + fontSize: 14, + fontWeight: '600', + color: colors.text, + }, + inset && { paddingLeft: 32 }, + style, + ]); + + return ; +}); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const flattenStyle = StyleSheet.flatten([ + { + marginHorizontal: -4, + marginVertical: 4, + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderMedium, + }, + style, + ]); + + return ; +}); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = (props: React.ComponentPropsWithoutRef) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + + ); +}; +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +}; + +const styles = StyleSheet.create({ + content: { + zIndex: 50, + minWidth: 128, + overflow: 'hidden', + borderRadius: 6, + borderWidth: 1, + padding: 4, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + item: { + position: 'relative', + flexDirection: 'row', + gap: 8, + alignItems: 'center', + borderRadius: 2, + paddingHorizontal: 8, + paddingVertical: 6, + }, + checkboxItem: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + borderRadius: 2, + paddingVertical: 6, + paddingLeft: 32, + paddingRight: 8, + }, + radioItem: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + borderRadius: 2, + paddingVertical: 6, + paddingLeft: 32, + paddingRight: 8, + }, + iconWrapper: { + position: 'absolute', + left: 8, + height: 14, + width: 14, + alignItems: 'center', + justifyContent: 'center', + }, +}); diff --git a/apps/expo-stylesheet/components/ui/hover-card.tsx b/apps/expo-stylesheet/components/ui/hover-card.tsx new file mode 100644 index 00000000..4b775498 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/hover-card.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { Platform, StyleSheet } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { TextClassContext } from '~/components/ui/text'; +import * as HoverCardPrimitive from '@rn-primitives/hover-card'; +import { useTheme } from '@react-navigation/native'; + +const HoverCard = HoverCardPrimitive.Root; + +const HoverCardTrigger = HoverCardPrimitive.Trigger; + +const HoverCardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ align = 'center', sideOffset = 4, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenContentStyles = StyleSheet.flatten([ + styles.base, + { + borderColor: colors.border, + backgroundColor: colors.card, + shadowColor: colors.text, + }, + style, + ]); + + return ( + + + + + + + + + + ); +}); +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName; + +export { HoverCard, HoverCardContent, HoverCardTrigger }; + +const styles = StyleSheet.create({ + base: { + zIndex: 50, + width: 256, + borderRadius: 6, + borderWidth: 1, + padding: 16, + shadowOpacity: 0.05, + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 4, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/input.tsx b/apps/expo-stylesheet/components/ui/input.tsx new file mode 100644 index 00000000..4aefd4d1 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/input.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { TextInput, StyleSheet, Platform } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { type ICustomTheme } from '~/lib/constants'; + +const Input = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, editable = true, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + + ); +}); + +Input.displayName = 'Input'; + +export { Input }; + +const styles = StyleSheet.create({ + base: { + height: 40, + borderRadius: 6, + borderWidth: 1, + paddingHorizontal: 12, + fontSize: Platform.OS === 'web' ? 14 : 16, + lineHeight: 20, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/label.tsx b/apps/expo-stylesheet/components/ui/label.tsx new file mode 100644 index 00000000..02dc700b --- /dev/null +++ b/apps/expo-stylesheet/components/ui/label.tsx @@ -0,0 +1,35 @@ +import * as LabelPrimitive from '@rn-primitives/label'; +import * as React from 'react'; +import { Platform, StyleSheet } from 'react-native'; +import { useTheme } from '@react-navigation/native'; + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, onPress, onLongPress, onPressIn, onPressOut, ...props }, ref) => { + const { colors } = useTheme(); + const flattenStyle = StyleSheet.flatten([styles.text, { color: colors.text }, style]); + + return ( + + + + ); +}); + +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; + +const styles = StyleSheet.create({ + text: { + fontSize: 14, + fontWeight: '500', + lineHeight: Platform.OS === 'web' ? 16 : 20, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/menubar.tsx b/apps/expo-stylesheet/components/ui/menubar.tsx new file mode 100644 index 00000000..b9aa14d5 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/menubar.tsx @@ -0,0 +1,444 @@ +import * as MenubarPrimitive from '@rn-primitives/menubar'; +import * as React from 'react'; +import { Platform, Text, View, StyleSheet, ViewStyle, StyleProp } from 'react-native'; +import { Check, ChevronDown, ChevronRight, ChevronUp } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { TextClassContext } from '~/components/ui/text'; +import { ICustomTheme } from '~/lib/constants'; + +const MenubarMenu = MenubarPrimitive.Menu; + +const MenubarGroup = MenubarPrimitive.Group; + +const MenubarPortal = MenubarPrimitive.Portal; + +const MenubarSub = MenubarPrimitive.Sub; + +const MenubarRadioGroup = MenubarPrimitive.RadioGroup; + +const Menubar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + ); +}); +Menubar.displayName = MenubarPrimitive.Root.displayName; + +const MenubarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, ...props }, ref) => { + const { value } = MenubarPrimitive.useRootContext(); + const { value: itemValue } = MenubarPrimitive.useMenuContext(); + const { colors } = useTheme() as ICustomTheme; + const isActive = value === itemValue; + const nativeStyle = ({ pressed }: { pressed: boolean }) => [ + styles.trigger, + { backgroundColor: isActive ? colors.accent : 'transparent' }, + pressed && { backgroundColor: colors.accent }, + style, + ]; + const webStyle = StyleSheet.flatten([ + styles.trigger, + { backgroundColor: isActive ? colors.accent : 'transparent' }, + style, + ]); + + return ( + + + + ); +}); +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName; + +const MenubarSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + style?: StyleProp; + } +>(({ inset, children, style, ...props }, ref) => { + const { open } = MenubarPrimitive.useSubContext(); + const { colors } = useTheme() as ICustomTheme; + const Icon = Platform.OS === 'web' ? ChevronRight : open ? ChevronUp : ChevronDown; + const nativeStyle = ({ pressed }: { pressed: boolean }) => [ + styles.subTrigger, + { backgroundColor: open ? colors.accent : 'transparent' }, + inset && { paddingLeft: 32 }, + pressed && { backgroundColor: colors.accent }, + style, + ]; + const webStyle = StyleSheet.flatten([ + styles.subTrigger, + { backgroundColor: open ? colors.accent : 'transparent' }, + inset && { paddingLeft: 32 }, + style, + ]); + + return ( + + + <>{children} + + + + ); +}); +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; + +const MenubarSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + ); +}); +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName; + +const MenubarContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + portalHost?: string; + } +>(({ portalHost, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenContentStyles = StyleSheet.flatten([ + styles.content, + { + borderColor: colors.border, + backgroundColor: colors.card, + shadowColor: colors.text, + }, + style, + ]); + + return ( + + + + ); +}); +MenubarContent.displayName = MenubarPrimitive.Content.displayName; + +const MenubarItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + style?: StyleProp; + } +>(({ inset, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStyle = ({ pressed }: { pressed: boolean }) => [ + styles.item, + inset && { paddingLeft: 32 }, + props.disabled && { opacity: 0.5 }, + pressed && { backgroundColor: colors.accent }, + style, + ]; + const webStyle = StyleSheet.flatten([ + styles.item, + inset && { paddingLeft: 32 }, + props.disabled && { opacity: 0.5 }, + style, + ]); + + return ( + + + + ); +}); +MenubarItem.displayName = MenubarPrimitive.Item.displayName; + +const MenubarCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, checked, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStyle = ({ pressed }: { pressed: boolean }) => [ + styles.checkboxItem, + props.disabled && { opacity: 0.5 }, + pressed && { backgroundColor: colors.accent }, + ]; + const webStyle = StyleSheet.flatten([styles.checkboxItem, props.disabled && { opacity: 0.5 }]); + + return ( + + + + + + + + <>{children} + + + ); +}); +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName; + +const MenubarRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ children, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStyle = ({ pressed }: { pressed: boolean }) => [ + styles.radioItem, + props.disabled && { opacity: 0.5 }, + pressed && { backgroundColor: colors.accent }, + style, + ]; + const webStyle = StyleSheet.flatten([ + styles.radioItem, + props.disabled && { opacity: 0.5 }, + style, + ]); + + return ( + + + + + + + + <>{children} + + + ); +}); +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName; + +const MenubarLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ inset, style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + ); +}); +MenubarLabel.displayName = MenubarPrimitive.Label.displayName; + +const MenubarSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenStyle = StyleSheet.flatten([ + styles.separator, + { backgroundColor: colors.border }, + style, + ]); + + return ; +}); +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName; + +const MenubarShortcut = ({ style, ...props }: React.ComponentPropsWithoutRef) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + + ); +}; +MenubarShortcut.displayName = 'MenubarShortcut'; + +export { + Menubar, + MenubarCheckboxItem, + MenubarContent, + MenubarGroup, + MenubarItem, + MenubarLabel, + MenubarMenu, + MenubarPortal, + MenubarRadioGroup, + MenubarRadioItem, + MenubarSeparator, + MenubarShortcut, + MenubarSub, + MenubarSubContent, + MenubarSubTrigger, + MenubarTrigger, +}; + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + height: 44, + alignItems: 'center', + borderRadius: 6, + borderWidth: 1, + padding: 4, + }, + trigger: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 2, + paddingHorizontal: Platform.OS === 'web' ? 12 : 16, + paddingVertical: 6, + }, + subTrigger: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderRadius: 2, + paddingHorizontal: 8, + paddingVertical: 6, + }, + subContent: { + zIndex: 50, + minWidth: 128, + overflow: 'hidden', + borderRadius: 6, + borderWidth: 1, + marginTop: 4, + padding: 4, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + content: { + zIndex: 50, + minWidth: 128, + overflow: 'hidden', + borderRadius: 6, + borderWidth: 1, + padding: 4, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + item: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderRadius: 2, + paddingHorizontal: 8, + paddingVertical: 6, + }, + checkboxItem: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + borderRadius: 2, + paddingVertical: 6, + paddingLeft: 32, + paddingRight: 8, + }, + radioItem: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + borderRadius: 2, + paddingVertical: 6, + paddingLeft: 32, + paddingRight: 8, + }, + indicatorBox: { + position: 'absolute', + left: 8, + height: 14, + width: 14, + alignItems: 'center', + justifyContent: 'center', + }, + label: { + paddingHorizontal: 8, + paddingVertical: 6, + fontSize: 14, + fontWeight: '600', + }, + separator: { + marginHorizontal: -4, + marginVertical: 4, + height: 1, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/navigation-menu.tsx b/apps/expo-stylesheet/components/ui/navigation-menu.tsx new file mode 100644 index 00000000..8ff762c7 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/navigation-menu.tsx @@ -0,0 +1,268 @@ +import * as NavigationMenuPrimitive from '@rn-primitives/navigation-menu'; +import * as React from 'react'; +import { Platform, View, StyleSheet, StyleProp, ViewStyle } from 'react-native'; +import Animated, { + Extrapolation, + FadeInLeft, + FadeOutLeft, + interpolate, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import { ChevronDown } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { ICustomTheme } from '~/lib/constants'; + +const NavigationMenu = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, style, ...props }, ref) => ( + + {children} + {Platform.OS === 'web' && } + +)); +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName; + +const NavigationMenuList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const flattenStyle = StyleSheet.flatten([styles.list, style]); + + return ; +}); +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName; + +const NavigationMenuItem = NavigationMenuPrimitive.Item; + +const navigationMenuTriggerStyle = { + flexDirection: 'row', + height: 40, + width: 'auto', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 6, + paddingHorizontal: 9, + paddingVertical: 8, +} as ViewStyle; + +const NavigationMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ children, disabled, style, ...props }, ref) => { + const { value } = NavigationMenuPrimitive.useRootContext(); + const { value: itemValue } = NavigationMenuPrimitive.useItemContext(); + const { colors } = useTheme() as ICustomTheme; + const nativeStyle = ({ pressed }: { pressed: boolean }) => [ + navigationMenuTriggerStyle, + style, + { + gap: 6, + backgroundColor: value === itemValue ? colors.accent : 'transparent', + opacity: disabled ? 0.5 : 1, + }, + pressed && { backgroundColor: colors.accent }, + ]; + const webStyle = StyleSheet.flatten([ + navigationMenuTriggerStyle, + style, + { + gap: 6, + backgroundColor: value === itemValue ? colors.accent : 'transparent', + opacity: disabled ? 0.5 : 1, + }, + ]); + + const progress = useDerivedValue(() => + value === itemValue ? withTiming(1, { duration: 250 }) : withTiming(0, { duration: 200 }) + ); + const chevronStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${progress.value * 180}deg` }], + opacity: interpolate(progress.value, [0, 1], [1, 0.8], Extrapolation.CLAMP), + })); + + return ( + + <>{children} + + + + + ); +}); +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName; + +const NavigationMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + portalHost?: string; + } +>(({ children, portalHost, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenContentStyles = StyleSheet.flatten([ + styles.content, + { + borderColor: colors.border, + backgroundColor: colors.card, + }, + style, + ]); + + return ( + + + + {children} + + + + ); +}); +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName; + +// const NavigationMenuLink = NavigationMenuPrimitive.Link; +const NavigationMenuLink = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const nativeStyle = ({ pressed }: { pressed: boolean }) => [ + style, + pressed && { backgroundColor: colors.accent }, + ]; + const webStyle: ViewStyle = StyleSheet.flatten([{}, style]); + + return ( + + ); +}); +NavigationMenuLink.displayName = NavigationMenuPrimitive.Link.displayName; + +const NavigationMenuViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + + + + + ); +}); +NavigationMenuViewport.displayName = NavigationMenuPrimitive.Viewport.displayName; + +const NavigationMenuIndicator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + + + ); +}); +NavigationMenuIndicator.displayName = NavigationMenuPrimitive.Indicator.displayName; + +export { + NavigationMenu, + NavigationMenuContent, + NavigationMenuIndicator, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + NavigationMenuViewport, + navigationMenuTriggerStyle, +}; + +const styles = StyleSheet.create({ + root: { + position: 'relative', + zIndex: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + list: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }, + content: { + width: '100%', + borderWidth: 1, + borderRadius: 8, + overflow: 'hidden', + shadowOpacity: 0.2, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + }, + viewportContainer: { + position: 'absolute', + left: 0, + top: '100%', + justifyContent: 'center', + }, + viewport: { + position: 'relative', + marginTop: 6, + width: '100%', + overflow: 'hidden', + borderRadius: 6, + borderWidth: 1, + shadowOpacity: 0.2, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + }, + indicator: { + top: '100%', + zIndex: 1, + height: 6, + alignItems: 'flex-end', + justifyContent: 'center', + overflow: 'hidden', + }, + indicatorSquare: { + position: 'relative', + top: '60%', + height: 8, + width: 8, + transform: [{ rotate: '45deg' }], + borderTopLeftRadius: 2, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/popover.tsx b/apps/expo-stylesheet/components/ui/popover.tsx new file mode 100644 index 00000000..04d3cce5 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/popover.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { Platform, StyleSheet } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { TextClassContext } from '~/components/ui/text'; +import * as PopoverPrimitive from '@rn-primitives/popover'; +import { useTheme } from '@react-navigation/native'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { portalHost?: string } +>(({ align = 'center', sideOffset = 4, portalHost, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenContentStyles = StyleSheet.flatten([ + styles.content, + { + backgroundColor: colors.card, + borderColor: colors.border, + shadowColor: colors.text, + }, + style, + ]); + + return ( + + + + + + + + + + ); +}); + +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverContent, PopoverTrigger }; + +const styles = StyleSheet.create({ + content: { + zIndex: 50, + width: 288, + borderRadius: 6, + borderWidth: 1, + padding: 16, + shadowOpacity: 0.05, + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 4, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/progress.tsx b/apps/expo-stylesheet/components/ui/progress.tsx new file mode 100644 index 00000000..9e77c7fd --- /dev/null +++ b/apps/expo-stylesheet/components/ui/progress.tsx @@ -0,0 +1,90 @@ +import * as ProgressPrimitive from '@rn-primitives/progress'; +import * as React from 'react'; +import { Platform, View, StyleSheet, ViewStyle } from 'react-native'; +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, + useDerivedValue, + withSpring, +} from 'react-native-reanimated'; +import { useTheme } from '@react-navigation/native'; +import { type ICustomTheme } from '~/lib/constants'; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + indicatorStyle?: ViewStyle; + } +>(({ style, value, indicatorStyle, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const flattenStyle = StyleSheet.flatten([styles.root, { backgroundColor: colors.accent }, style]); + + return ( + + + + ); +}); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; + +function Indicator({ + value, + indicatorStyle, +}: { + value: number | undefined | null; + indicatorStyle?: ViewStyle; +}) { + const { colors } = useTheme(); + const progress = useDerivedValue(() => value ?? 0); + const flattenWebIndicatorStyle = StyleSheet.flatten([styles.webIndicator, indicatorStyle]); + + const animatedStyle = useAnimatedStyle(() => { + return { + width: withSpring( + `${interpolate(progress.value, [0, 100], [1, 100], Extrapolation.CLAMP)}%`, + { overshootClamping: true } + ), + height: '100%', + backgroundColor: colors.primary, + ...indicatorStyle, + }; + }); + + if (Platform.OS === 'web') { + return ( + + + + ); + } + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { + position: 'relative', + height: Platform.OS === 'web' ? 16 : 14, + width: '100%', + overflow: 'hidden', + borderRadius: 9999, + }, + webIndicator: { + height: '100%', + width: '100%', + flex: 1, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/radio-group.tsx b/apps/expo-stylesheet/components/ui/radio-group.tsx new file mode 100644 index 00000000..8d071898 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/radio-group.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native'; +import * as RadioGroupPrimitive from '@rn-primitives/radio-group'; +import { useTheme } from '@react-navigation/native'; + +const RadioGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const flattenStyle = StyleSheet.flatten([styles.group, style]); + + return ; +}); +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName; + +const RadioGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, disabled, ...props }, ref) => { + const { colors } = useTheme(); + const flattenRadioItemStyle = StyleSheet.flatten([ + styles.item, + { + borderColor: colors.primary, + opacity: disabled ? 0.5 : 1, + }, + style, + ]); + + return ( + + + + + + ); +}); +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName; + +export { RadioGroup, RadioGroupItem }; + +const styles = StyleSheet.create({ + group: { + flexDirection: 'column', + gap: 8, + }, + item: { + aspectRatio: 1, + height: 16, + width: 16, + borderRadius: 9999, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + }, + indicatorWrapper: { + alignItems: 'center', + justifyContent: 'center', + }, + indicator: { + aspectRatio: 1, + height: 9, + width: 9, + borderRadius: 9999, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/select.tsx b/apps/expo-stylesheet/components/ui/select.tsx index a3f58565..d45de8d4 100644 --- a/apps/expo-stylesheet/components/ui/select.tsx +++ b/apps/expo-stylesheet/components/ui/select.tsx @@ -1,10 +1,10 @@ import * as SelectPrimitive from '@rn-primitives/select'; import * as React from 'react'; -import { Platform, View, StyleSheet, StyleProp, ViewStyle } from 'react-native'; +import { Platform, View, StyleSheet, ViewStyle, StyleProp } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; -import { Check } from '~/lib/icons/Check'; -import { ChevronDown } from '~/lib/icons/ChevronDown'; -import { ChevronUp } from '~/lib/icons/ChevronUp'; +import { useTheme } from '@react-navigation/native'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react-native'; +import { type ICustomTheme } from '~/lib/constants'; type Option = SelectPrimitive.Option; @@ -16,49 +16,39 @@ const SelectValue = SelectPrimitive.Value; const SelectTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ style, children, ...props }, ref) => ( - - {({ pressed }) => ( - , - ])} - > - <>{children} - - - )} - -)); + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ children, style, disabled, ...props }, ref) => { + const { colors } = useTheme(); + const flattenStyle = StyleSheet.flatten([ + styles.trigger, + { backgroundColor: colors.card, borderColor: colors.border }, + disabled && { opacity: 0.5 }, + style, + ]); + + return ( + + <>{children} + + + ); +}); SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; /** * Platform: WEB ONLY */ -const SelectScrollUpButton = ({ - className, - ...props -}: React.ComponentPropsWithoutRef) => { - if (Platform.OS !== 'web') { - return null; - } +const SelectScrollUpButton = ( + props: React.ComponentPropsWithoutRef +) => { + const { colors } = useTheme(); + if (Platform.OS !== 'web') return null; + return ( - - + + ); }; @@ -66,67 +56,52 @@ const SelectScrollUpButton = ({ /** * Platform: WEB ONLY */ -const SelectScrollDownButton = ({ - className, - ...props -}: React.ComponentPropsWithoutRef) => { - if (Platform.OS !== 'web') { - return null; - } +const SelectScrollDownButton = ( + props: React.ComponentPropsWithoutRef +) => { + const { colors } = useTheme(); + if (Platform.OS !== 'web') return null; return ( - - + + ); }; const SelectContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & { portalHost?: string } ->(({ style, children, position = 'popper', portalHost, ...props }, ref) => { + React.ComponentPropsWithoutRef & { + portalHost?: string; + } +>(({ children, position = 'popper', portalHost, style, ...props }, ref) => { + // const { open } = SelectPrimitive.useRootContext(); + const { colors } = useTheme(); + const flattenOverlayStyles = StyleSheet.flatten([ + StyleSheet.absoluteFillObject, + { backgroundColor: 'rgba(0,0,0,0.3)' }, + ]); + const flattenContentStyles = StyleSheet.flatten([ + styles.content, + { + backgroundColor: colors.card, + borderColor: colors.border, + shadowColor: colors.text, + }, + style, + ]); // single style object + return ( - + - - {children} - + {children} @@ -139,83 +114,129 @@ SelectContent.displayName = SelectPrimitive.Content.displayName; const SelectLabel = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ style, ...props }, ref) => ( - -)); +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenStyle = StyleSheet.flatten([styles.label, { color: colors.text }, style]); + + return ; +}); SelectLabel.displayName = SelectPrimitive.Label.displayName; const SelectItem = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ style, children, ...props }, ref) => ( - , - ])} - {...props} - > - & { + style?: StyleProp; + } +>(({ children, disabled, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const flattenItemTextStyle = StyleSheet.flatten([styles.itemText, { color: colors.text }]); + const webIndicatorStyle = StyleSheet.flatten([styles.item, disabled && { opacity: 0.5 }, style]); + const nativeIndicatorStyle = ({ pressed }: { pressed: boolean }) => [ + styles.item, + disabled && { opacity: 0.5 }, + pressed && { backgroundColor: colors.accent }, + style, + ]; + + return ( + - - - - - - -)); + + + + + + + + ); +}); SelectItem.displayName = SelectPrimitive.Item.displayName; const SelectSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ style, ...props }, ref) => ( - -)); +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + + ); +}); SelectSeparator.displayName = SelectPrimitive.Separator.displayName; +const styles = StyleSheet.create({ + trigger: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + height: 44, + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 12, + }, + scrollButton: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 4, + }, + content: { + borderWidth: 1, + borderRadius: 6, + paddingVertical: 8, + paddingHorizontal: 4, + maxHeight: 300, + minWidth: 120, + shadowOpacity: 0.1, + shadowOffset: { width: 0, height: 2 }, + shadowRadius: 4, + }, + viewport: { + padding: 4, + }, + label: { + paddingVertical: 6, + paddingBottom: 8, + paddingLeft: 40, + paddingRight: 8, + fontSize: 14, + fontWeight: '600', + }, + item: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + paddingLeft: 40, + paddingRight: 8, + borderRadius: 6, + }, + itemIndicatorWrapper: { + position: 'absolute', + left: 14, + height: 14, + width: 14, + paddingTop: 2, + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', + }, + itemText: { + fontSize: Platform.OS === 'web' ? 14 : 16, + lineHeight: Platform.OS === 'web' ? 28 : 24, + }, + separator: { + height: 1, + marginVertical: 8, + }, +}); + export { Select, SelectContent, diff --git a/apps/expo-stylesheet/components/ui/separator.tsx b/apps/expo-stylesheet/components/ui/separator.tsx new file mode 100644 index 00000000..105865c4 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import * as SeparatorPrimitive from '@rn-primitives/separator'; +import { useTheme } from '@react-navigation/native'; +import { ViewStyle } from 'react-native'; +import { type ICustomTheme } from '~/lib/constants'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ orientation = 'horizontal', decorative = true, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + const separatorStyle: ViewStyle = + orientation === 'horizontal' ? { height: 1, width: '100%' } : { width: 1, height: '100%' }; + + return ( + + ); +}); + +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/expo-stylesheet/components/ui/switch.tsx b/apps/expo-stylesheet/components/ui/switch.tsx new file mode 100644 index 00000000..5e5f1cde --- /dev/null +++ b/apps/expo-stylesheet/components/ui/switch.tsx @@ -0,0 +1,138 @@ +import * as SwitchPrimitives from '@rn-primitives/switch'; +import * as React from 'react'; +import { Platform, StyleSheet, StyleProp, ViewStyle } from 'react-native'; +import Animated, { + interpolateColor, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; +import { useTheme } from '@react-navigation/native'; +import { type ICustomTheme } from '~/lib/constants'; + +const SwitchWeb = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, checked, disabled, ...props }, ref) => { + const { colors } = useTheme(); + const flattenRootStyle = StyleSheet.flatten([ + styles.webRoot, + { + backgroundColor: checked ? colors.primary : colors.border, + opacity: disabled ? 0.5 : 1, + }, + style, + ]); + const flattenThumbStyle = StyleSheet.flatten([ + styles.webThumb, + { + backgroundColor: colors.background, + shadowColor: colors.text, + transform: [{ translateX: checked ? 20 : 0 }], + }, + ]); + + return ( + + + + ); +}); +SwitchWeb.displayName = 'SwitchWeb'; + +const SwitchNative = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + style?: StyleProp; + } +>(({ style, checked, disabled, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const translateX = useDerivedValue(() => (checked ? 18 : 0)); + const animatedRootStyle = useAnimatedStyle(() => { + return { + backgroundColor: interpolateColor(translateX.value, [0, 18], [colors.muted, colors.primary]), + opacity: disabled ? 0.5 : 1, + }; + }); + + const animatedThumbStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: withTiming(translateX.value, { duration: 200 }) }], + })); + + return ( + + + + + + + + ); +}); +SwitchNative.displayName = 'SwitchNative'; + +const Switch = Platform.select({ + web: SwitchWeb, + default: SwitchNative, +}); + +export { Switch }; + +const styles = StyleSheet.create({ + webRoot: { + flexDirection: 'row', + height: 24, + width: 44, + flexShrink: 0, + cursor: 'pointer', + alignItems: 'center', + borderRadius: 9999, + borderWidth: 2, + borderColor: 'transparent', + }, + webThumb: { + pointerEvents: 'none', + height: 20, + width: 20, + borderRadius: 9999, + shadowOpacity: 0.1, + shadowRadius: 2, + }, + nativeRoot: { + height: 28, + width: 46, + borderRadius: 9999, + }, + nativeInnerRoot: { + flexDirection: 'row', + height: 28, + width: 46, + flexShrink: 0, + alignItems: 'center', + borderRadius: 9999, + borderWidth: 2, + borderColor: 'transparent', + }, + nativeThumb: { + height: 24, + width: 24, + borderRadius: 9999, + shadowOpacity: 0.25, + shadowRadius: 3, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/table.tsx b/apps/expo-stylesheet/components/ui/table.tsx new file mode 100644 index 00000000..757cab95 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/table.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; +import * as TablePrimitive from '@rn-primitives/table'; +import { StyleSheet, ViewStyle, StyleProp } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { TextClassContext } from '~/components/ui/text'; +import { type ICustomTheme } from '~/lib/constants'; + +const Table = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => ( + +)); +Table.displayName = 'Table'; + +const TableHeader = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + ); +}); +TableHeader.displayName = 'TableHeader'; + +const TableBody = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme(); + + return ( + + ); +}); +TableBody.displayName = 'TableBody'; + +const TableFooter = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + + ); +}); +TableFooter.displayName = 'TableFooter'; + +const TableRow = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { style?: StyleProp } +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + [ + styles.row, + { borderColor: colors.borderMedium }, + style, + pressed && { backgroundColor: colors.accent }, + hovered && { backgroundColor: colors.accent }, + ]} + {...props} + /> + ); +}); +TableRow.displayName = 'TableRow'; + +const TableHead = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + + return ( + + + + ); +}); +TableHead.displayName = 'TableHead'; + +const TableCell = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + return ; +}); +TableCell.displayName = 'TableCell'; + +export { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow }; + +const styles = StyleSheet.create({ + table: { + width: '100%', + fontSize: 14, + }, + header: { + borderBottomWidth: StyleSheet.hairlineWidth, + }, + body: { + flex: 1, + minHeight: 2, + }, + footer: { + fontWeight: '500', + borderBottomWidth: 0, + }, + row: { + flexDirection: 'row', + borderBottomWidth: StyleSheet.hairlineWidth, + }, + head: { + height: 48, + paddingHorizontal: 16, + textAlign: 'left', + justifyContent: 'center', + fontWeight: '500', + }, + cell: { + padding: 16, + verticalAlign: 'middle', + }, +}); diff --git a/apps/expo-stylesheet/components/ui/tabs.tsx b/apps/expo-stylesheet/components/ui/tabs.tsx new file mode 100644 index 00000000..6c167ed4 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/tabs.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import { StyleSheet, ViewStyle, StyleProp } from 'react-native'; +import { TextClassContext } from '~/components/ui/text'; +import * as TabsPrimitive from '@rn-primitives/tabs'; +import { useTheme } from '@react-navigation/native'; +import { type ICustomTheme } from '~/lib/constants'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const flattenStyle = StyleSheet.flatten([styles.list, { backgroundColor: colors.muted }, style]); + + return ; +}); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { style?: StyleProp } +>(({ style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const { value } = TabsPrimitive.useRootContext(); + const isActive = value === props.value; + + return ( + + + + ); +}); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ style, ...props }, ref) => ); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsContent, TabsList, TabsTrigger }; + +const styles = StyleSheet.create({ + list: { + height: 40, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 8, + padding: 4, + }, + trigger: { + alignItems: 'center', + justifyContent: 'center', + borderRadius: 2, + paddingHorizontal: 12, + paddingVertical: 6, + shadowOpacity: 0.1, + shadowRadius: 2, + shadowOffset: { width: 0, height: 1 }, + }, + triggerText: { + fontSize: 14, + fontWeight: '500', + }, +}); diff --git a/apps/expo-stylesheet/components/ui/text.tsx b/apps/expo-stylesheet/components/ui/text.tsx new file mode 100644 index 00000000..3e6f5ecf --- /dev/null +++ b/apps/expo-stylesheet/components/ui/text.tsx @@ -0,0 +1,31 @@ +import { useTheme } from '@react-navigation/native'; +import * as Slot from '@rn-primitives/slot'; +import type { SlottableTextProps, TextRef } from '@rn-primitives/types'; +import * as React from 'react'; +import { Platform, Text as RNText, StyleProp, TextStyle } from 'react-native'; +import { type ICustomTheme } from '~/lib/constants'; + +const TextClassContext = React.createContext>(undefined); + +const Text = React.forwardRef( + ({ asChild = false, style, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const contextStyle = React.useContext(TextClassContext); + const Component = asChild ? Slot.Text : RNText; + + return ( + + ); + } +); +Text.displayName = 'Text'; + +export { Text, TextClassContext }; diff --git a/apps/expo-stylesheet/components/ui/toggle-group.tsx b/apps/expo-stylesheet/components/ui/toggle-group.tsx new file mode 100644 index 00000000..d289a485 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/toggle-group.tsx @@ -0,0 +1,102 @@ +import type { LucideIcon } from 'lucide-react-native'; +import * as React from 'react'; +import { useTheme } from '@react-navigation/native'; +import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; +import { toggleTextVariants, ToggleProps, toggleVariants } from '~/components/ui/toggle'; +import { TextClassContext } from '~/components/ui/text'; +import * as ToggleGroupPrimitive from '@rn-primitives/toggle-group'; +import { type ICustomTheme } from '~/lib/constants'; + +const ToggleGroupContext = React.createContext(null); + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & ToggleProps +>(({ style, variant, size, children, ...props }, ref) => { + const flattenStyle = StyleSheet.flatten([styles.group, style]); + + return ( + + + {children} + + + ); +}); + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName; + +function useToggleGroupContext() { + const context = React.useContext(ToggleGroupContext); + if (context === null) { + throw new Error( + 'ToggleGroup compound components cannot be rendered outside the ToggleGroup component' + ); + } + return context; +} + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + ToggleProps & { + style?: StyleProp; + } +>(({ children, variant, size, style, ...props }, ref) => { + const context = useToggleGroupContext(); + const { value } = ToggleGroupPrimitive.useRootContext(); + const { colors } = useTheme() as ICustomTheme; + + const isSelected = ToggleGroupPrimitive.utils.getIsSelected(value, props.value); + const flattenToggleGroupStyle = StyleSheet.flatten([ + toggleVariants({ + variant: context.variant || variant, + size: context.size || size, + colors, + }), + { + backgroundColor: isSelected ? colors.accent : colors.card, + borderColor: colors.borderMedium, + borderWidth: StyleSheet.hairlineWidth, + }, + props.disabled && { opacity: 0.5 }, + style, + ]); + + return ( + + + {children} + + + ); +}); + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName; + +function ToggleGroupIcon({ + icon: Icon, + style, + ...props +}: React.ComponentPropsWithoutRef & { icon: LucideIcon }) { + const textClass = React.useContext(TextClassContext); + const flattenTextStyles = StyleSheet.flatten(textClass); + + return ; +} + +export { ToggleGroup, ToggleGroupIcon, ToggleGroupItem }; + +const styles = StyleSheet.create({ + group: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 4, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/toggle.tsx b/apps/expo-stylesheet/components/ui/toggle.tsx new file mode 100644 index 00000000..503b8e7c --- /dev/null +++ b/apps/expo-stylesheet/components/ui/toggle.tsx @@ -0,0 +1,136 @@ +import * as React from 'react'; +import { StyleProp, StyleSheet, TextStyle, ViewStyle } from 'react-native'; +import type { LucideIcon } from 'lucide-react-native'; +import { useTheme } from '@react-navigation/native'; +import { TextClassContext } from '~/components/ui/text'; +import * as TogglePrimitive from '@rn-primitives/toggle'; +import { type ICustomTheme, type ICustomThemeColor } from '~/lib/constants'; + +type Variant = 'default' | 'outline'; +type Size = 'default' | 'sm' | 'lg'; +type ToggleProps = { variant?: Variant; size?: Size }; + +const toggleVariants = ({ + variant = 'default', + size = 'default', + colors, + style, +}: { + variant?: Variant; + size?: Size; + colors: ICustomThemeColor; + style?: StyleProp; +}): ViewStyle => { + let variantStyle: ViewStyle = {}; + let sizeStyle: ViewStyle = {}; + + // === VARIANT STYLES === + if (variant === 'default') { + variantStyle = { backgroundColor: 'transparent' }; + } else if (variant === 'outline') { + variantStyle = { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.borderMedium, + backgroundColor: 'transparent', + }; + } + + // === SIZE STYLES === + if (size === 'default') { + sizeStyle = { height: 42, paddingHorizontal: 12 }; + } else if (size === 'sm') { + sizeStyle = { height: 36, paddingHorizontal: 10 }; + } else if (size === 'lg') { + sizeStyle = { height: 44, paddingHorizontal: 20 }; + } + + return StyleSheet.flatten([styles.baseToggle, variantStyle, sizeStyle, style]); +}; + +const toggleTextVariants = ({ + variant = 'default', + size = 'default', + colors, + style, +}: { + variant?: Variant; + size?: Size; + colors: ICustomThemeColor; + style?: StyleProp; +}): TextStyle => { + let variantStyle: TextStyle = {}; + let sizeStyle: TextStyle = {}; + + // === VARIANT STYLES === + if (variant === 'default') variantStyle = {}; + else if (variant === 'outline') variantStyle = {}; + + // === SIZE STYLES === + if (size === 'default') sizeStyle = {}; + else if (size === 'sm') sizeStyle = {}; + else if (size === 'lg') sizeStyle = {}; + + return StyleSheet.flatten([ + styles.baseToggleText, + variantStyle, + sizeStyle, + { color: colors.text }, + style, + ]); +}; + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + ToggleProps & { + style?: StyleProp; + textStyle?: StyleProp; + } +>(({ variant, size, style, textStyle, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const flattenToggleStyle = StyleSheet.flatten([ + toggleVariants({ variant, size, colors, style }), + props.disabled && { opacity: 0.5 }, + props.pressed && { backgroundColor: colors.accent }, + ]); + + return ( + + + + ); +}); + +Toggle.displayName = TogglePrimitive.Root.displayName; + +function ToggleIcon({ + icon: Icon, + style, + ...props +}: React.ComponentPropsWithoutRef & { + icon: LucideIcon; + style?: StyleProp; +}) { + const { colors } = useTheme(); + + return ; +} + +export { Toggle, ToggleIcon, toggleTextVariants, toggleVariants, ToggleProps }; + +const styles = StyleSheet.create({ + baseToggle: { + alignItems: 'center', + justifyContent: 'center', + borderRadius: 6, + }, + baseToggleText: { + fontSize: 14, + fontWeight: '500', + }, +}); diff --git a/apps/expo-stylesheet/components/ui/tooltip.tsx b/apps/expo-stylesheet/components/ui/tooltip.tsx new file mode 100644 index 00000000..b82b399d --- /dev/null +++ b/apps/expo-stylesheet/components/ui/tooltip.tsx @@ -0,0 +1,64 @@ +import * as TooltipPrimitive from '@rn-primitives/tooltip'; +import * as React from 'react'; +import { Platform, StyleSheet } from 'react-native'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { TextClassContext } from '~/components/ui/text'; +import { useTheme } from '@react-navigation/native'; + +const Tooltip = TooltipPrimitive.Root; +const TooltipTrigger = TooltipPrimitive.Trigger; + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + portalHost?: string; + } +>(({ sideOffset = 4, portalHost, style, ...props }, ref) => { + const { colors } = useTheme(); + const flattenContentStyles = StyleSheet.flatten([ + styles.content, + { + borderColor: colors.border, + backgroundColor: colors.card, + shadowColor: colors.text, + }, + style, + ]); + + return ( + + + + + + + + + + ); +}); +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipContent, TooltipTrigger }; + +const styles = StyleSheet.create({ + content: { + zIndex: 50, + overflow: 'hidden', + borderRadius: 6, + borderWidth: 1, + paddingHorizontal: 12, + paddingVertical: 6, + shadowOpacity: 0.05, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + }, +}); diff --git a/apps/expo-stylesheet/components/ui/typography.tsx b/apps/expo-stylesheet/components/ui/typography.tsx new file mode 100644 index 00000000..21afc3c7 --- /dev/null +++ b/apps/expo-stylesheet/components/ui/typography.tsx @@ -0,0 +1,245 @@ +import * as Slot from '@rn-primitives/slot'; +import { SlottableTextProps, TextRef } from '@rn-primitives/types'; +import * as React from 'react'; +import { Platform, StyleSheet, Text as RNText } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { ICustomTheme } from '~/lib/constants'; + +const H1 = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme(); + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [styles.h1, { color: colors.text }, style]; + + return ; + } +); + +H1.displayName = 'H1'; + +const H2 = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme(); + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [ + styles.h2, + { + color: colors.text, + borderBottomColor: colors.border, + }, + style, + ]; + + return ; + } +); + +H2.displayName = 'H2'; + +const H3 = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme(); + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [styles.h3, { color: colors.text }, style]; + + return ; + } +); + +H3.displayName = 'H3'; + +const H4 = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme(); + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [styles.h4, { color: colors.text }, style]; + + return ; + } +); + +H4.displayName = 'H4'; + +const P = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme(); + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [styles.p, { color: colors.text }, style]; + + return ; + } +); + +P.displayName = 'P'; + +const BlockQuote = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme(); + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [ + styles.blockquote, + { + color: colors.text, + borderColor: colors.border, + }, + style, + ]; + + return ( + + ); + } +); + +BlockQuote.displayName = 'BlockQuote'; + +const Code = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [ + styles.code, + { + color: colors.text, + backgroundColor: colors.muted, + }, + style, + ]; + + return ( + + ); + } +); + +Code.displayName = 'Code'; + +const Lead = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [styles.lead, { color: colors.mutedText }, style]; + + return ; + } +); + +Lead.displayName = 'Lead'; + +const Large = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme(); + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [styles.large, { color: colors.text }, style]; + + return ; + } +); + +Large.displayName = 'Large'; + +const Small = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme(); + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [styles.small, { color: colors.text }, style]; + + return ; + } +); + +Small.displayName = 'Small'; + +const Muted = React.forwardRef( + ({ style, asChild = false, ...props }, ref) => { + const { colors } = useTheme() as ICustomTheme; + const Component = asChild ? Slot.Text : RNText; + const themedStyle = [styles.muted, { color: colors.mutedText }, style]; + + return ; + } +); + +Muted.displayName = 'Muted'; + +export { BlockQuote, Code, H1, H2, H3, H4, Large, Lead, Muted, P, Small }; + +const styles = StyleSheet.create({ + h1: { + fontSize: Platform.OS === 'web' ? 48 : 36, + lineHeight: 40, + fontWeight: '800', + letterSpacing: -0.5, + }, + h2: { + fontSize: 30, + lineHeight: 36, + fontWeight: '600', + letterSpacing: -0.25, + paddingBottom: 8, + borderBottomWidth: 1, + }, + h3: { + fontSize: 24, + lineHeight: 32, + fontWeight: '600', + letterSpacing: -0.25, + }, + h4: { + fontSize: 20, + lineHeight: 28, + fontWeight: '600', + letterSpacing: -0.25, + }, + p: { + fontSize: 16, + lineHeight: 24, + }, + blockquote: { + marginTop: 24, + borderLeftWidth: 2, + paddingLeft: 24, + fontSize: 16, + lineHeight: 24, + fontStyle: 'italic', + }, + code: { + position: 'relative', + borderRadius: 6, + paddingHorizontal: 4.8, + paddingVertical: 3.2, + fontSize: 14, + lineHeight: 20, + fontWeight: '600', + }, + lead: { + fontSize: 20, + lineHeight: 28, + }, + large: { + fontSize: 20, + lineHeight: 28, + fontWeight: '600', + }, + small: { + fontSize: 14, + fontWeight: '500', + lineHeight: 16, + }, + muted: { + fontSize: 14, + lineHeight: 20, + }, +}); diff --git a/apps/expo-stylesheet/lib/constants.ts b/apps/expo-stylesheet/lib/constants.ts index 7c3d02fb..f674022a 100644 --- a/apps/expo-stylesheet/lib/constants.ts +++ b/apps/expo-stylesheet/lib/constants.ts @@ -1,18 +1,100 @@ -export const NAV_THEME = { +export const NAV_THEME: Record<'light' | 'dark', ICustomThemeColor> = { light: { background: 'hsl(0 0% 100%)', // background border: 'hsl(240 5.9% 90%)', // border + borderMedium: 'hsl(240 5.9% 65%)', // border card: 'hsl(0 0% 100%)', // card notification: 'hsl(0 84.2% 60.2%)', // destructive primary: 'hsl(240 5.9% 10%)', // primary text: 'hsl(240 10% 3.9%)', // foreground + + // Input / Muted + muted: 'hsl(240 4.8% 95.9%)', // subtle background + mutedText: 'hsl(240 3.8% 46.1%)', // placeholder/secondary + accent: 'hsl(240 4.8% 92.9%)', // accent background color + accentText: 'hsl(240 5.9% 10%)', // accent text color + accentMild: 'hsl(240 4.8% 98%)', // accent mild background color + ring: 'hsl(240 4.9% 83.9%)', // focus ring + + // Button-specific + buttonPrimary: 'hsl(240 5.9% 10%)', + buttonPrimaryText: 'hsl(0 0% 100%)', + + buttonSecondary: 'hsl(240 4.8% 95.9%)', + buttonSecondaryText: 'hsl(240 10% 3.9%)', + + buttonGhost: 'transparent', + buttonGhostText: 'hsl(240 10% 3.9%)', + + buttonOutline: 'transparent', + buttonOutlineText: 'hsl(240 10% 3.9%)', + + buttonLink: 'transparent', + buttonLinkText: 'hsl(240 5.9% 10%)', }, dark: { background: 'hsl(240 10% 3.9%)', // background - border: 'hsl(240 3.7% 15.9%)', // border + border: 'hsl(240 3.7% 18%)', // border + borderMedium: 'hsl(240 3.7% 35%)', // border card: 'hsl(240 10% 3.9%)', // card notification: 'hsl(0 72% 51%)', // destructive primary: 'hsl(0 0% 98%)', // primary text: 'hsl(0 0% 98%)', // foreground + + // Input / Muted + muted: 'hsl(240 3.7% 15.9%)', // subtle background + mutedText: 'hsl(240 5% 64.9%)', // placeholder/secondary + accent: 'hsl(240 3.7% 17.9%)', // accent background color + accentText: 'hsl(0 0% 98%)', // accent text color + accentMild: 'hsl(240 3.7% 8%)', // accent mild background color + ring: 'hsl(240 4% 16.9%)', // focus ring + + // Button-specific + buttonPrimary: 'hsl(0 0% 98%)', + buttonPrimaryText: 'hsl(240 10% 3.9%)', + + buttonSecondary: 'hsl(240 3.7% 13%)', + buttonSecondaryText: 'hsl(0 0% 98%)', + + buttonGhost: 'transparent', + buttonGhostText: 'hsl(0 0% 98%)', + + buttonOutline: 'transparent', + buttonOutlineText: 'hsl(0 0% 98%)', + + buttonLink: 'transparent', + buttonLinkText: 'hsl(0 0% 98%)', }, }; + +export interface ICustomThemeColor { + background: string; + border: string; + borderMedium: string; + card: string; + notification: string; + primary: string; + text: string; + + // Input / Muted + muted: string; + mutedText: string; + accent: string; + accentText: string; + accentMild: string; + ring: string; + + // Button-specific + buttonPrimary: string; + buttonPrimaryText: string; + buttonSecondary: string; + buttonSecondaryText: string; + buttonGhost: string; + buttonGhostText: string; + buttonOutline: string; + buttonOutlineText: string; + buttonLink: string; + buttonLinkText: string; +} + +export type ICustomTheme = ReactNavigation.Theme & { colors: ICustomThemeColor }; diff --git a/apps/expo-stylesheet/lib/icons/Check.tsx b/apps/expo-stylesheet/lib/icons/Check.tsx deleted file mode 100644 index 3dae6da1..00000000 --- a/apps/expo-stylesheet/lib/icons/Check.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Check } from 'lucide-react-native'; -export { Check }; diff --git a/apps/expo-stylesheet/lib/icons/ChevronDown.tsx b/apps/expo-stylesheet/lib/icons/ChevronDown.tsx deleted file mode 100644 index 845819ff..00000000 --- a/apps/expo-stylesheet/lib/icons/ChevronDown.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { ChevronDown } from 'lucide-react-native'; -export { ChevronDown }; diff --git a/apps/expo-stylesheet/lib/icons/ChevronRight.tsx b/apps/expo-stylesheet/lib/icons/ChevronRight.tsx deleted file mode 100644 index 5ef3d560..00000000 --- a/apps/expo-stylesheet/lib/icons/ChevronRight.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { ChevronRight } from 'lucide-react-native'; -export { ChevronRight }; diff --git a/apps/expo-stylesheet/lib/icons/ChevronUp.tsx b/apps/expo-stylesheet/lib/icons/ChevronUp.tsx deleted file mode 100644 index 19f1400b..00000000 --- a/apps/expo-stylesheet/lib/icons/ChevronUp.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { ChevronUp } from 'lucide-react-native'; -export { ChevronUp }; diff --git a/apps/expo-stylesheet/lib/icons/index.ts b/apps/expo-stylesheet/lib/icons/index.ts deleted file mode 100644 index 09d58ff4..00000000 --- a/apps/expo-stylesheet/lib/icons/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { Check } from './Check'; -export { ChevronDown } from './ChevronDown'; -export { ChevronRight } from './ChevronRight'; -export { ChevronUp } from './ChevronUp'; diff --git a/apps/expo-stylesheet/lib/useColorScheme.tsx b/apps/expo-stylesheet/lib/useColorScheme.tsx new file mode 100644 index 00000000..32ec52d7 --- /dev/null +++ b/apps/expo-stylesheet/lib/useColorScheme.tsx @@ -0,0 +1,29 @@ +import { Appearance } from 'react-native'; +import { create } from 'zustand'; + +export function useColorScheme() { + const colorScheme = useColorSchemeStore((state) => state.colorScheme); + const setColorScheme = useColorSchemeStore((state) => state.setColorScheme); + const toggleColorScheme = useColorSchemeStore((state) => state.toggleColorScheme); + + return { + colorScheme, + setColorScheme, + toggleColorScheme, + }; +} + +interface IUseColorSchemeStore { + colorScheme: 'light' | 'dark'; + setColorScheme: (colorScheme: 'light' | 'dark') => void; + toggleColorScheme: () => void; +} + +export const useColorSchemeStore = create((set) => ({ + colorScheme: Appearance.getColorScheme() ?? 'dark', + setColorScheme: (colorScheme) => set({ colorScheme }), + toggleColorScheme: () => + set((state) => ({ + colorScheme: state.colorScheme === 'dark' ? 'light' : 'dark', + })), +})); diff --git a/apps/expo-stylesheet/lib/utils.ts b/apps/expo-stylesheet/lib/utils.ts new file mode 100644 index 00000000..eaed5d86 --- /dev/null +++ b/apps/expo-stylesheet/lib/utils.ts @@ -0,0 +1,38 @@ +import { ViewStyle, TextStyle } from 'react-native'; + +export const mergeBaseStyleWithUserStyle = ( + baseStyle: (TemplateStyle | false | undefined | null)[], + userStyle: any +): (TemplateStyle | false | undefined | null)[] => { + if (isViewStyleArray(userStyle)) { + // unfortunately, we can't use '...style' directly. + // See isViewStyleArray() function for more details. + return [...baseStyle, ...Object.values(userStyle as object)]; + } + + return [...baseStyle, userStyle]; +}; + +const isViewStyleArray = (obj: any): boolean => { + if (obj == null) return false; + if (Array.isArray(obj)) return true; + if (typeof obj !== 'object') return false; + + const keysArray = Object.keys(obj); + + /** + * unfortunately, Array.isArray(obj) is not working. It always return `false` + * For some reason in case of an Array, we are getting an object + * where keys are indexes like '0', '1', etc and values are the ViewStyle Object. + * Example: + * { + * "0": {"alignItems": "center", "borderLeftWidth": 1, "borderRadius": 0}, + * "1": {"backgroundColor": "white"}, + * "2": false, + * "3": {"flex": 1} + * } + */ + if (keysArray.includes('0')) return true; + + return false; +}; diff --git a/package.json b/package.json index a6955e5e..4513a8fa 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "dev": "turbo dev --concurrency=34", "dev:primitives": "turbo dev --filter=./packages/* --concurrency=33", "dev:expo-nativewind": "turbo dev --filter=./apps/expo-nativewind", + "dev:expo-stylesheet": "turbo dev --filter=./apps/expo-stylesheet", "dev:nextjs-nativewind": "turbo dev --filter=./apps/nextjs-nativewind", "dev:docs": "turbo dev --filter=./apps/docs", "version-bump": "npx changeset && npx changeset version",