diff --git a/js/components/src/components/ui/button.tsx b/js/components/src/components/ui/button.tsx new file mode 100644 index 00000000..08e336ba --- /dev/null +++ b/js/components/src/components/ui/button.tsx @@ -0,0 +1,309 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import React, { forwardRef, useMemo } from "react"; +import { ActivityIndicator, StyleSheet } from "react-native"; +import { useTheme } from "../../lib/theme/theme"; +import * as tokens from "../../lib/theme/tokens"; +import { ButtonPrimitive, ButtonPrimitiveProps } from "./primitives/button"; +import { TextPrimitive } from "./primitives/text"; + +// Button variants using class-variance-authority pattern +const buttonVariants = cva("", { + variants: { + variant: { + primary: "primary", + secondary: "secondary", + outline: "outline", + ghost: "ghost", + destructive: "destructive", + success: "success", + }, + size: { + sm: "sm", + md: "md", + lg: "lg", + xl: "xl", + pill: "pill", + }, + }, + defaultVariants: { + variant: "primary", + size: "md", + }, +}); + +export interface ButtonProps + extends Omit, + VariantProps { + children?: React.ReactNode; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + loading?: boolean; + loadingText?: string; +} + +export const Button = forwardRef( + ( + { + variant = "primary", + size = "md", + children, + leftIcon, + rightIcon, + loading = false, + loadingText, + disabled, + style, + ...props + }, + ref, + ) => { + const { theme } = useTheme(); + + // Create dynamic styles based on theme + const styles = useMemo(() => createStyles(theme), [theme]); + + // Get variant styles + const buttonStyle = useMemo(() => { + const variantStyle = styles[`${variant}Button` as keyof typeof styles]; + const sizeStyle = styles[`${size}Button` as keyof typeof styles]; + return [variantStyle, sizeStyle]; + }, [variant, size, styles]); + + // Get inner styles for button content + const buttonInnerStyle = useMemo(() => { + const sizeInnerStyle = + styles[`${size}ButtonInner` as keyof typeof styles]; + return sizeInnerStyle; + }, [size, styles]); + + const textStyle = React.useMemo(() => { + const variantTextStyle = styles[`${variant}Text` as keyof typeof styles]; + const sizeTextStyle = styles[`${size}Text` as keyof typeof styles]; + return [variantTextStyle, sizeTextStyle]; + }, [variant, size, styles]); + + const iconSize = React.useMemo(() => { + switch (size) { + case "sm": + return 16; + case "lg": + return 20; + case "xl": + return 24; + case "md": + default: + return 18; + } + }, [size]); + + const spinnerSize = useMemo(() => { + switch (size) { + case "sm": + return "small" as const; + case "lg": + case "xl": + return "large" as const; + case "md": + default: + return "small" as const; + } + }, [size]); + + const spinnerColor = useMemo(() => { + switch (variant) { + case "outline": + case "ghost": + return theme.colors.primary; + case "secondary": + return theme.colors.secondaryForeground; + case "destructive": + return theme.colors.destructiveForeground; + default: + return theme.colors.primaryForeground; + } + }, [variant, theme.colors]); + + return ( + + + {loading && !leftIcon ? ( + + + + ) : leftIcon ? ( + + {leftIcon} + + ) : null} + + + {loading && loadingText ? loadingText : children} + + + {loading && rightIcon ? ( + + + + ) : rightIcon ? ( + + {rightIcon} + + ) : null} + + + ); + }, +); + +Button.displayName = "Button"; + +// Create theme-based styles +function createStyles(theme: any) { + return StyleSheet.create({ + // Variant styles + primaryButton: { + backgroundColor: theme.colors.primary, + borderWidth: 0, + ...theme.shadows.sm, + }, + primaryText: { + color: theme.colors.primaryForeground, + fontWeight: "600", + }, + + secondaryButton: { + backgroundColor: theme.colors.secondary, + borderWidth: 0, + }, + secondaryText: { + color: theme.colors.secondaryForeground, + fontWeight: "500", + }, + + outlineButton: { + backgroundColor: "transparent", + borderWidth: 1, + borderColor: theme.colors.border, + }, + outlineText: { + color: theme.colors.foreground, + fontWeight: "500", + }, + + ghostButton: { + backgroundColor: "transparent", + borderWidth: 0, + }, + ghostText: { + color: theme.colors.foreground, + fontWeight: "500", + }, + + destructiveButton: { + backgroundColor: theme.colors.destructive, + borderWidth: 0, + ...theme.shadows.sm, + }, + destructiveText: { + color: theme.colors.destructiveForeground, + fontWeight: "600", + }, + + successButton: { + backgroundColor: theme.colors.success, + borderWidth: 0, + ...theme.shadows.sm, + }, + successText: { + color: theme.colors.successForeground, + fontWeight: "600", + }, + + pillButton: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: tokens.borderRadius.full, + minHeight: tokens.touchTargets.minimum / 2, + }, + + pillText: { + color: theme.colors.primaryForeground, + fontWeight: "400", + }, + + // Size styles + smButton: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: tokens.borderRadius.md, + minHeight: tokens.touchTargets.minimum, + gap: theme.spacing[1], + }, + smButtonInner: { + gap: theme.spacing[1], + }, + smText: { + fontSize: 14, + lineHeight: 16, + }, + + mdButton: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderRadius: tokens.borderRadius.md, + minHeight: tokens.touchTargets.minimum, + gap: theme.spacing[2], + }, + mdButtonInner: { + gap: theme.spacing[2], + }, + mdText: { + fontSize: 16, + lineHeight: 18, + }, + + lgButton: { + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[4], + borderRadius: tokens.borderRadius.md, + minHeight: tokens.touchTargets.comfortable, + gap: theme.spacing[3], + }, + lgButtonInner: { + gap: theme.spacing[3], + }, + lgText: { + fontSize: 18, + lineHeight: 20, + }, + + xlButton: { + paddingHorizontal: theme.spacing[8], + paddingVertical: theme.spacing[5], + borderRadius: tokens.borderRadius.lg, + minHeight: tokens.touchTargets.large, + gap: theme.spacing[4], + }, + xlButtonInner: { + gap: theme.spacing[4], + }, + xlText: { + fontSize: 20, + lineHeight: 24, + }, + }); +} + +// Export button variants for external use +export { buttonVariants }; diff --git a/js/components/src/components/ui/dialog.tsx b/js/components/src/components/ui/dialog.tsx new file mode 100644 index 00000000..73042c1c --- /dev/null +++ b/js/components/src/components/ui/dialog.tsx @@ -0,0 +1,376 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react-native"; +import React, { forwardRef } from "react"; +import { Platform, StyleSheet, Text } from "react-native"; +import { useTheme } from "../../lib/theme/theme"; +import { createThemedIcon } from "./icons"; +import { ModalPrimitive, ModalPrimitiveProps } from "./primitives/modal"; + +const ThemedX = createThemedIcon(X); + +// Dialog variants using class-variance-authority pattern +const dialogVariants = cva("", { + variants: { + variant: { + default: "default", + sheet: "sheet", + fullscreen: "fullscreen", + }, + size: { + sm: "sm", + md: "md", + lg: "lg", + xl: "xl", + full: "full", + }, + position: { + center: "center", + top: "top", + bottom: "bottom", + left: "left", + right: "right", + }, + }, + defaultVariants: { + variant: "default", + size: "md", + position: "center", + }, +}); + +export interface DialogProps + extends Omit, + VariantProps { + children?: React.ReactNode; + title?: string; + description?: string; + dismissible?: boolean; + showCloseButton?: boolean; + onClose?: () => void; +} + +export const Dialog = forwardRef( + ( + { + variant = "left", + size = "md", + position = "center", + children, + title, + description, + dismissible = true, + showCloseButton = true, + onClose, + open = false, + onOpenChange, + ...props + }, + ref, + ) => { + const { theme } = useTheme(); + + // Create dynamic styles based on theme + const styles = React.useMemo(() => createStyles(theme), [theme]); + + const handleClose = React.useCallback(() => { + if (onClose) { + onClose(); + } + if (onOpenChange) { + onOpenChange(false); + } + }, [onClose, onOpenChange]); + + const presentationStyle = React.useMemo(() => { + if (variant === "sheet" && Platform.OS === "ios") { + return "pageSheet" as const; + } + if (variant === "fullscreen") { + return "fullScreen" as const; + } + return Platform.OS === "ios" + ? ("pageSheet" as const) + : ("fullScreen" as const); + }, [variant]); + + const animationType = React.useMemo(() => { + if (variant === "sheet") { + return "slide" as const; + } + return "fade" as const; + }, [variant]); + + return ( + + + + {(title || showCloseButton) && ( + + {title} + {showCloseButton && ( + + + + )} + + )} + + + {description && ( + {description} + )} + {children} + + + + + ); + }, +); + +Dialog.displayName = "Dialog"; + +// Dialog Title component +export interface DialogTitleProps { + children?: React.ReactNode; + style?: any; +} + +export const DialogTitle = forwardRef( + ({ children, style, ...props }, ref) => { + const { theme } = useTheme(); + const styles = React.useMemo(() => createStyles(theme), [theme]); + + if (!children) return null; + + return ( + + {children} + + ); + }, +); + +DialogTitle.displayName = "DialogTitle"; + +// Dialog Description component +export interface DialogDescriptionProps { + children?: React.ReactNode; + style?: any; +} + +export const DialogDescription = forwardRef( + ({ children, style, ...props }, ref) => { + const { theme } = useTheme(); + const styles = React.useMemo(() => createStyles(theme), [theme]); + + if (!children) return null; + + return ( + + {children} + + ); + }, +); + +DialogDescription.displayName = "DialogDescription"; + +// Dialog Footer component +export interface DialogFooterProps { + children?: React.ReactNode; + direction?: "row" | "column"; + justify?: + | "flex-start" + | "center" + | "flex-end" + | "space-between" + | "space-around"; + withBorder?: boolean; + style?: any; +} + +export const DialogFooter = forwardRef( + ( + { + children, + direction = "row", + justify = "flex-end", + withBorder = true, + style, + ...props + }, + ref, + ) => { + const { theme } = useTheme(); + const styles = React.useMemo(() => createStyles(theme), [theme]); + + if (!children) return null; + + return ( + + {children} + + ); + }, +); + +DialogFooter.displayName = "DialogFooter"; + +// Dialog Close Icon component (Lucide X) +const DialogCloseIcon = () => { + return ; +}; + +// Create theme-aware styles +function createStyles(theme: any) { + return StyleSheet.create({ + overlay: { + backgroundColor: "rgba(0, 0, 0, 0.5)", + }, + + content: { + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + ...theme.shadows.lg, + maxHeight: "90%", + maxWidth: "90%", + }, + + // Variant styles + defaultContent: { + // Default styles already applied in content + }, + + sheetContent: { + borderTopLeftRadius: theme.borderRadius.xl, + borderTopRightRadius: theme.borderRadius.xl, + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + marginTop: "auto", + marginBottom: 0, + maxHeight: "80%", + width: "100%", + maxWidth: "100%", + }, + + fullscreenContent: { + width: "100%", + height: "100%", + maxWidth: "100%", + maxHeight: "100%", + borderRadius: 0, + margin: 0, + }, + + // Size styles + smContent: { + minWidth: 300, + minHeight: 200, + }, + + mdContent: { + minWidth: 400, + minHeight: 300, + }, + + lgContent: { + minWidth: 500, + minHeight: 400, + }, + + xlContent: { + minWidth: 600, + minHeight: 500, + }, + + fullContent: { + width: "95%", + height: "95%", + maxWidth: "95%", + maxHeight: "95%", + }, + + header: { + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[4], + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + + body: { + paddingHorizontal: theme.spacing[6], + paddingBottom: theme.spacing[6], + flex: 1, + }, + + footer: { + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[4], + gap: theme.spacing[2], + }, + + title: { + fontSize: 20, + fontWeight: "600", + color: theme.colors.text, + flex: 1, + lineHeight: 24, + }, + + description: { + fontSize: 16, + color: theme.colors.textMuted, + lineHeight: 22, + marginBottom: theme.spacing[4], + }, + + closeButton: { + width: theme.touchTargets.minimum, + height: theme.touchTargets.minimum, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.sm, + marginLeft: theme.spacing[2], + }, + }); +} + +// Export dialog variants for external use +export { dialogVariants }; diff --git a/js/components/src/components/ui/icons.tsx b/js/components/src/components/ui/icons.tsx new file mode 100644 index 00000000..7544e4c9 --- /dev/null +++ b/js/components/src/components/ui/icons.tsx @@ -0,0 +1,50 @@ +import { type LucideProps } from "lucide-react-native"; +import React from "react"; +import { useTheme } from "../../lib/theme"; + +// Simple icon wrapper that integrates with theme +export interface IconProps { + variant?: + | "default" + | "muted" + | "primary" + | "secondary" + | "destructive" + | "success" + | "warning"; + size?: number | "sm" | "md" | "lg" | "xl"; + color?: string; +} + +// Size mapping +const sizeMap = { + sm: 16, + md: 20, + lg: 24, + xl: 32, +} as const; + +// HOC to create themed icons +export function createThemedIcon( + IconComponent: React.ComponentType, +): React.FC { + return ({ variant = "default", size = "md", color, ...restProps }) => { + let theme = useTheme(); // Ensure theme is available + // Calculate size + const iconSize = typeof size === "number" ? size : sizeMap[size]; + + // Calculate color if not provided using atoms + const iconColor = + color || + theme.theme.colors[variant] || + theme.theme.colors.secondaryForeground; + + return ( + )} + /> + ); + }; +} diff --git a/js/components/src/components/ui/index.ts b/js/components/src/components/ui/index.ts new file mode 100644 index 00000000..368065ad --- /dev/null +++ b/js/components/src/components/ui/index.ts @@ -0,0 +1,27 @@ +// Export primitive components +export * from "./primitives/button"; +export * from "./primitives/input"; +export * from "./primitives/modal"; +export * from "./primitives/text"; + +// Export styled components +export * from "./button"; +export * from "./dialog"; +export * from "./icons"; +export * from "./input"; +export * from "./text"; +export * from "./view"; + +// Component collections for easy importing +export { ButtonPrimitive } from "./primitives/button"; +export { InputPrimitive } from "./primitives/input"; +export { ModalPrimitive } from "./primitives/modal"; +export { TextPrimitive } from "./primitives/text"; + +// Re-export commonly used types +export type { Theme } from "../../lib/theme/theme"; +export type { ButtonProps } from "./button"; +export type { DialogProps } from "./dialog"; +export type { InputProps } from "./input"; +export type { TextProps } from "./text"; +export type { ViewProps } from "./view"; diff --git a/js/components/src/components/ui/input.tsx b/js/components/src/components/ui/input.tsx new file mode 100644 index 00000000..364f3897 --- /dev/null +++ b/js/components/src/components/ui/input.tsx @@ -0,0 +1,350 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import React, { forwardRef } from "react"; +import { Platform, StyleSheet, TouchableWithoutFeedback } from "react-native"; +import { useTheme } from "../../lib/theme/theme"; +import { InputPrimitive, InputPrimitiveProps } from "./primitives/input"; + +const inputVariants = cva("", { + variants: { + variant: { + default: "default", + filled: "filled", + underlined: "underlined", + }, + size: { + sm: "sm", + md: "md", + lg: "lg", + }, + }, + defaultVariants: { + variant: "default", + size: "md", + }, +}); + +export interface InputProps + extends Omit, + VariantProps { + label?: string; + description?: string; + error?: string; + required?: boolean; + leftAddon?: React.ReactNode; + rightAddon?: React.ReactNode; + containerStyle?: any; + inputStyle?: any; +} + +export const Input = forwardRef( + ( + { + variant = "default", + size = "md", + label, + description, + error, + required = false, + leftAddon, + rightAddon, + disabled = false, + containerStyle, + inputStyle, + ...props + }, + ref, + ) => { + const { theme } = useTheme(); + const [isFocused, setIsFocused] = React.useState(false); + const inputRef = React.useRef(null); + + // Create dynamic styles based on theme + const styles = React.useMemo(() => createStyles(theme), [theme]); + + // Get variant and size styles + const containerStyles = React.useMemo(() => { + const variantStyle = styles[`${variant}Container` as keyof typeof styles]; + const sizeStyle = styles[`${size}Container` as keyof typeof styles]; + const focusStyle = isFocused ? styles.focusedContainer : null; + return [variantStyle, sizeStyle, focusStyle]; + }, [variant, size, styles, isFocused]); + + const textStyles = React.useMemo(() => { + const variantTextStyle = styles[`${variant}Input` as keyof typeof styles]; + const sizeTextStyle = styles[`${size}Input` as keyof typeof styles]; + return [variantTextStyle, sizeTextStyle]; + }, [variant, size, styles]); + + const handleFocus = React.useCallback( + (event: any) => { + setIsFocused(true); + if (props.onFocus) { + props.onFocus(event); + } + }, + [props.onFocus], + ); + + const handleBlur = React.useCallback( + (event: any) => { + setIsFocused(false); + if (props.onBlur) { + props.onBlur(event); + } + }, + [props.onBlur], + ); + + const handleContainerPress = React.useCallback(() => { + if (inputRef.current && !disabled) { + inputRef.current.focus(); + } + }, [disabled]); + + const hasAddons = leftAddon || rightAddon; + + if (hasAddons) { + return ( + + {label && ( + + {label} + + )} + + + + {leftAddon && ( + + {leftAddon} + + )} + + { + inputRef.current = node; + if (ref) { + if (typeof ref === "function") { + ref(node); + } else { + ref.current = node; + } + } + }} + disabled={disabled} + error={!!error} + onFocus={handleFocus} + onBlur={handleBlur} + style={[ + textStyles, + styles.inputInContainer, + inputStyle, + { outline: "none" }, + ]} + placeholderTextColor={ + disabled ? theme.colors.textDisabled : theme.colors.textMuted + } + {...props} + /> + + {rightAddon && ( + + {rightAddon} + + )} + + + + {description && !error && ( + + {description} + + )} + + {error} + + ); + } + + return ( + + {label && ( + + {label} + + )} + + { + inputRef.current = node; + if (ref) { + if (typeof ref === "function") { + ref(node); + } else { + ref.current = node; + } + } + }} + disabled={disabled} + error={!!error} + onFocus={handleFocus} + onBlur={handleBlur} + style={[containerStyles, textStyles, containerStyle, inputStyle]} + placeholderTextColor={ + disabled ? theme.colors.textDisabled : theme.colors.textMuted + } + {...props} + /> + + {description && !error && ( + + {description} + + )} + + {error} + + ); + }, +); + +Input.displayName = "Input"; + +// Create theme-aware styles +function createStyles(theme: any) { + return StyleSheet.create({ + // Variant styles for containers + defaultContainer: { + backgroundColor: theme.colors.background, + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.md, + }, + + filledContainer: { + backgroundColor: theme.colors.muted, + borderWidth: 0, + borderRadius: theme.borderRadius.md, + }, + + underlinedContainer: { + backgroundColor: "transparent", + borderWidth: 0, + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + borderRadius: 0, + paddingHorizontal: 0, + }, + + // Variant styles for inputs + defaultInput: { + color: theme.colors.text, + backgroundColor: "transparent", + }, + + filledInput: { + color: theme.colors.text, + backgroundColor: "transparent", + }, + + underlinedInput: { + color: theme.colors.text, + backgroundColor: "transparent", + }, + + // Size styles for containers + smContainer: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + minHeight: theme.touchTargets.minimum - 8, + }, + + mdContainer: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[3], + minHeight: theme.touchTargets.minimum, + }, + + lgContainer: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[4], + minHeight: theme.touchTargets.comfortable, + }, + + // Size styles for inputs + smInput: { + fontSize: 14, + lineHeight: 18, + ...Platform.select({ + ios: { + paddingVertical: 0, + }, + android: { + paddingVertical: 0, + textAlignVertical: "center", + }, + }), + }, + + mdInput: { + fontSize: 16, + lineHeight: 20, + ...Platform.select({ + ios: { + paddingVertical: 0, + }, + android: { + paddingVertical: 0, + textAlignVertical: "center", + }, + }), + }, + + lgInput: { + fontSize: 18, + lineHeight: 22, + ...Platform.select({ + ios: { + paddingVertical: 0, + }, + android: { + paddingVertical: 0, + textAlignVertical: "center", + }, + }), + }, + + // Special style for inputs inside containers + inputInContainer: { + flex: 1, + paddingHorizontal: 0, + paddingVertical: 0, + borderWidth: 0, + backgroundColor: "transparent", + minHeight: "auto", + borderRadius: 0, + }, + + // Focus styles + focusedContainer: { + borderColor: theme.colors.primary, + borderWidth: 2, + }, + }); +} + +// Export input variants for external use +export { inputVariants }; diff --git a/js/components/src/components/ui/primitives/button.tsx b/js/components/src/components/ui/primitives/button.tsx new file mode 100644 index 00000000..192b210a --- /dev/null +++ b/js/components/src/components/ui/primitives/button.tsx @@ -0,0 +1,292 @@ +import React, { forwardRef } from "react"; +import { + AccessibilityRole, + GestureResponderEvent, + StyleSheet, + Text, + TextProps, + TouchableOpacity, + TouchableOpacityProps, + View, + ViewProps, +} from "react-native"; + +// Base button primitive interface +export interface ButtonPrimitiveProps + extends Omit { + onPress?: (event: GestureResponderEvent) => void; + disabled?: boolean; + loading?: boolean; + accessibilityRole?: AccessibilityRole; + accessibilityLabel?: string; + accessibilityHint?: string; + testID?: string; +} + +// Button root primitive - handles all touch interactions +export const ButtonRoot = forwardRef< + React.ComponentRef, + ButtonPrimitiveProps +>( + ( + { + children, + disabled = false, + loading = false, + onPress, + onPressIn, + onPressOut, + onLongPress, + accessibilityRole = "button", + accessibilityLabel, + accessibilityHint, + accessibilityState, + testID, + style, + activeOpacity = 0.7, + ...props + }, + ref, + ) => { + const handlePress = React.useCallback( + (event: GestureResponderEvent) => { + if (!disabled && !loading && onPress) { + onPress(event); + } + }, + [disabled, loading, onPress], + ); + + const handlePressIn = React.useCallback( + (event: GestureResponderEvent) => { + if (!disabled && !loading && onPressIn) { + onPressIn(event); + } + }, + [disabled, loading, onPressIn], + ); + + const handlePressOut = React.useCallback( + (event: GestureResponderEvent) => { + if (!disabled && !loading && onPressOut) { + onPressOut(event); + } + }, + [disabled, loading, onPressOut], + ); + + const handleLongPress = React.useCallback( + (event: GestureResponderEvent) => { + if (!disabled && !loading && onLongPress) { + onLongPress(event); + } + }, + [disabled, loading, onLongPress], + ); + + return ( + + {children} + + ); + }, +); + +ButtonRoot.displayName = "ButtonRoot"; + +// Button text primitive +export interface ButtonTextProps extends TextProps { + disabled?: boolean; + loading?: boolean; +} + +export const ButtonText = forwardRef( + ({ children, disabled, loading, style, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +ButtonText.displayName = "ButtonText"; + +// Button icon primitive +export interface ButtonIconProps extends ViewProps { + position?: "left" | "right"; + disabled?: boolean; + loading?: boolean; +} + +export const ButtonIcon = forwardRef( + ( + { children, position = "left", disabled, loading, style, ...props }, + ref, + ) => { + return ( + + {children} + + ); + }, +); + +ButtonIcon.displayName = "ButtonIcon"; + +// Button loading indicator primitive +export interface ButtonLoadingProps extends ViewProps { + visible?: boolean; +} + +export const ButtonLoading = forwardRef( + ({ children, visible = false, style, ...props }, ref) => { + if (!visible) return null; + + return ( + + {children} + + ); + }, +); + +ButtonLoading.displayName = "ButtonLoading"; + +// Container for button content with flex layout +export interface ButtonContentProps extends ViewProps { + direction?: "row" | "column"; + align?: "flex-start" | "center" | "flex-end"; + justify?: + | "flex-start" + | "center" + | "flex-end" + | "space-between" + | "space-around"; +} + +export const ButtonContent = forwardRef( + ( + { + children, + direction = "row", + align = "center", + justify = "center", + style, + ...props + }, + ref, + ) => { + return ( + + {children} + + ); + }, +); + +ButtonContent.displayName = "ButtonContent"; + +// Primitive styles (minimal, unstyled) +const primitiveStyles = StyleSheet.create({ + button: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + minHeight: 44, // iOS minimum touch target + minWidth: 44, + }, + disabled: { + opacity: 0.5, + }, + content: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + flex: 1, + }, + text: { + textAlign: "center" as const, + }, + textDisabled: { + opacity: 0.5, + }, + icon: { + alignItems: "center", + justifyContent: "center", + }, + iconLeft: { + marginRight: 8, + }, + iconRight: { + marginLeft: 8, + }, + iconDisabled: { + opacity: 0.5, + }, + loading: { + position: "absolute", + alignItems: "center", + justifyContent: "center", + }, +}); + +// Export primitive collection +export const ButtonPrimitive = { + Root: ButtonRoot, + Text: ButtonText, + Icon: ButtonIcon, + Loading: ButtonLoading, + Content: ButtonContent, +}; diff --git a/js/components/src/components/ui/primitives/input.tsx b/js/components/src/components/ui/primitives/input.tsx new file mode 100644 index 00000000..4c8efddc --- /dev/null +++ b/js/components/src/components/ui/primitives/input.tsx @@ -0,0 +1,422 @@ +import React, { forwardRef } from "react"; +import { + NativeSyntheticEvent, + Platform, + StyleSheet, + Text, + TextInput, + TextInputFocusEventData, + TextInputProps, + TextProps, + TouchableOpacity, + View, + ViewProps, +} from "react-native"; + +// Base input primitive interface +export interface InputPrimitiveProps extends Omit { + error?: boolean; + disabled?: boolean; + loading?: boolean; + onChange?: (text: string) => void; + onFocus?: (event: NativeSyntheticEvent) => void; + onBlur?: (event: NativeSyntheticEvent) => void; +} + +// Input root primitive - the main TextInput component +export const InputRoot = forwardRef( + ( + { + value, + onChangeText, + onChange, + onFocus, + onBlur, + error = false, + disabled = false, + loading = false, + editable, + style, + placeholderTextColor = "#9ca3af", + ...props + }, + ref, + ) => { + const [isFocused, setIsFocused] = React.useState(false); + + const handleChangeText = React.useCallback( + (text: string) => { + if (onChangeText) { + onChangeText(text); + } + if (onChange) { + onChange(text); + } + }, + [onChangeText, onChange], + ); + + const handleFocus = React.useCallback( + (event: NativeSyntheticEvent) => { + setIsFocused(true); + if (onFocus) { + onFocus(event); + } + }, + [onFocus], + ); + + const handleBlur = React.useCallback( + (event: NativeSyntheticEvent) => { + setIsFocused(false); + if (onBlur) { + onBlur(event); + } + }, + [onBlur], + ); + + return ( + + ); + }, +); + +InputRoot.displayName = "InputRoot"; + +// Input container primitive - wraps input with additional elements +export interface InputContainerProps extends ViewProps { + focused?: boolean; + error?: boolean; + disabled?: boolean; +} + +export const InputContainer = forwardRef( + ( + { + children, + focused = false, + error = false, + disabled = false, + style, + ...props + }, + ref, + ) => { + return ( + + {children} + + ); + }, +); + +InputContainer.displayName = "InputContainer"; + +// Input label primitive +export interface InputLabelProps extends TextProps { + required?: boolean; + disabled?: boolean; + error?: boolean; +} + +export const InputLabel = forwardRef( + ( + { + children, + required = false, + disabled = false, + error = false, + style, + ...props + }, + ref, + ) => { + return ( + + {children} + {required && *} + + ); + }, +); + +InputLabel.displayName = "InputLabel"; + +// Input description/helper text primitive +export interface InputDescriptionProps extends TextProps { + error?: boolean; + disabled?: boolean; +} + +export const InputDescription = forwardRef( + ({ children, error = false, disabled = false, style, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +InputDescription.displayName = "InputDescription"; + +// Input error message primitive +export interface InputErrorProps extends TextProps { + visible?: boolean; +} + +export const InputError = forwardRef( + ({ children, visible = true, style, ...props }, ref) => { + if (!visible || !children) return null; + + return ( + + {children} + + ); + }, +); + +InputError.displayName = "InputError"; + +// Input addon primitive (for icons, buttons, etc.) +export interface InputAddonProps extends ViewProps { + position?: "left" | "right"; + touchable?: boolean; + onPress?: () => void; +} + +export const InputAddon = forwardRef< + React.ComponentRef | React.ComponentRef, + InputAddonProps +>( + ( + { + children, + position = "left", + touchable = false, + onPress, + style, + ...props + }, + ref, + ) => { + const addonStyle = [ + primitiveStyles.addon, + primitiveStyles[ + `addon${position.charAt(0).toUpperCase() + position.slice(1)}` as keyof typeof primitiveStyles + ], + style, + ]; + + if (touchable && onPress) { + return ( + >} + style={addonStyle as any} + onPress={onPress} + {...props} + > + {children} + + ); + } + + return ( + >} + style={addonStyle as any} + {...props} + > + {children} + + ); + }, +); + +InputAddon.displayName = "InputAddon"; + +// Input group primitive - groups label, input, description, error +export interface InputGroupProps extends ViewProps { + spacing?: number; +} + +export const InputGroup = forwardRef( + ({ children, spacing = 8, style, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +InputGroup.displayName = "InputGroup"; + +// Primitive styles (minimal, unstyled) +const primitiveStyles = StyleSheet.create({ + input: { + minHeight: 44, // iOS minimum touch target + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 16, + borderWidth: 1, + borderColor: "#d1d5db", + borderRadius: 8, + backgroundColor: "white", + ...Platform.select({ + ios: { + paddingVertical: 12, + }, + android: { + paddingVertical: 8, + textAlignVertical: "center", + }, + }), + }, + inputFocused: { + // No focus styles for the actual input + }, + inputError: { + borderColor: "#ef4444", + borderWidth: 2, + }, + inputDisabled: { + backgroundColor: "#f3f4f6", + borderColor: "#e5e7eb", + opacity: 0.6, + }, + inputLoading: { + opacity: 0.7, + }, + container: { + flexDirection: "row", + alignItems: "center", + borderWidth: 1, + borderColor: "#d1d5db", + borderRadius: 8, + backgroundColor: "white", + paddingHorizontal: 12, + minHeight: 44, + }, + containerFocused: { + borderColor: "#3b82f6", + borderWidth: 2, + }, + containerError: { + borderColor: "#ef4444", + borderWidth: 2, + }, + containerDisabled: { + backgroundColor: "#f3f4f6", + borderColor: "#e5e7eb", + opacity: 0.6, + }, + label: { + fontSize: 14, + fontWeight: "500", + color: "#374151", + marginBottom: 4, + }, + labelError: { + color: "#ef4444", + }, + labelDisabled: { + color: "#9ca3af", + opacity: 0.6, + }, + required: { + color: "#ef4444", + }, + description: { + fontSize: 12, + color: "#6b7280", + marginTop: 4, + }, + descriptionError: { + color: "#ef4444", + }, + descriptionDisabled: { + color: "#9ca3af", + opacity: 0.6, + }, + error: { + fontSize: 12, + color: "#ef4444", + marginTop: 4, + }, + addon: { + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 8, + }, + addonLeft: { + marginRight: 8, + }, + addonRight: { + marginLeft: 8, + }, + group: { + flexDirection: "column", + }, +}); + +// Export primitive collection +export const InputPrimitive = { + Root: InputRoot, + Container: InputContainer, + Label: InputLabel, + Description: InputDescription, + Error: InputError, + Addon: InputAddon, + Group: InputGroup, +}; diff --git a/js/components/src/components/ui/primitives/modal.tsx b/js/components/src/components/ui/primitives/modal.tsx new file mode 100644 index 00000000..47a4459a --- /dev/null +++ b/js/components/src/components/ui/primitives/modal.tsx @@ -0,0 +1,421 @@ +import React, { forwardRef } from "react"; +import { + Dimensions, + GestureResponderEvent, + Modal, + ModalProps, + Platform, + ScrollView, + ScrollViewProps, + StyleSheet, + TouchableOpacity, + TouchableOpacityProps, + View, + ViewProps, +} from "react-native"; + +const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + +// Base modal primitive interface +export interface ModalPrimitiveProps extends Omit { + open?: boolean; + onOpenChange?: (open: boolean) => void; + children?: React.ReactNode; +} + +// Modal root primitive - handles the native Modal component +export const ModalRoot = forwardRef( + ( + { + open = false, + onOpenChange, + children, + onRequestClose, + animationType = "fade", + presentationStyle = Platform.OS === "ios" ? "pageSheet" : "fullScreen", + transparent = true, + statusBarTranslucent = Platform.OS === "android", + ...props + }, + ref, + ) => { + const handleRequestClose = React.useCallback( + (e: any) => { + if (onOpenChange) { + onOpenChange(false); + } + if (onRequestClose) { + onRequestClose(e); + } + }, + [onOpenChange, onRequestClose], + ); + + return ( + + + {children} + + + ); + }, +); + +ModalRoot.displayName = "ModalRoot"; + +// Modal overlay primitive - semi-transparent background +export interface ModalOverlayProps extends TouchableOpacityProps { + dismissible?: boolean; + onDismiss?: () => void; +} + +export const ModalOverlay = forwardRef< + React.ElementRef, + ModalOverlayProps +>( + ( + { + dismissible = true, + onDismiss, + onPress, + style, + children, + activeOpacity = 1, + ...props + }, + ref, + ) => { + const handlePress = React.useCallback( + (event: GestureResponderEvent) => { + if (dismissible && onDismiss) { + onDismiss(); + } + if (onPress) { + onPress(event); + } + }, + [dismissible, onDismiss, onPress], + ); + + return ( + + {children} + + ); + }, +); + +ModalOverlay.displayName = "ModalOverlay"; + +// Modal content primitive - the actual content container +export interface ModalContentProps extends ViewProps { + position?: "center" | "top" | "bottom" | "left" | "right"; + size?: "sm" | "md" | "lg" | "xl" | "full"; +} + +export const ModalContent = forwardRef( + ( + { + children, + position = "center", + size = "md", + style, + onStartShouldSetResponder, + ...props + }, + ref, + ) => { + // Prevent touches from propagating to overlay + const handleStartShouldSetResponder = React.useCallback(() => { + return true; + }, []); + + const positionStyle = React.useMemo(() => { + switch (position) { + case "top": + return primitiveStyles.contentTop; + case "bottom": + return primitiveStyles.contentBottom; + case "left": + return primitiveStyles.contentLeft; + case "right": + return primitiveStyles.contentRight; + case "center": + default: + return primitiveStyles.contentCenter; + } + }, [position]); + + const sizeStyle = React.useMemo(() => { + switch (size) { + case "sm": + return primitiveStyles.sizeSm; + case "lg": + return primitiveStyles.sizeLg; + case "xl": + return primitiveStyles.sizeXl; + case "full": + return primitiveStyles.sizeFull; + case "md": + default: + return primitiveStyles.sizeMd; + } + }, [size]); + + return ( + + {children} + + ); + }, +); + +ModalContent.displayName = "ModalContent"; + +// Modal header primitive +export interface ModalHeaderProps extends ViewProps { + withBorder?: boolean; +} + +export const ModalHeader = forwardRef( + ({ children, withBorder = false, style, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +ModalHeader.displayName = "ModalHeader"; + +// Modal body primitive - scrollable content area +export interface ModalBodyProps extends ScrollViewProps { + scrollable?: boolean; +} + +export const ModalBody = forwardRef( + ({ children, scrollable = true, style, ...props }, ref) => { + if (!scrollable) { + return {children}; + } + + return ( + + {children} + + ); + }, +); + +ModalBody.displayName = "ModalBody"; + +// Modal footer primitive +export interface ModalFooterProps extends ViewProps { + withBorder?: boolean; + direction?: "row" | "column"; + justify?: + | "flex-start" + | "center" + | "flex-end" + | "space-between" + | "space-around"; +} + +export const ModalFooter = forwardRef( + ( + { + children, + withBorder = false, + direction = "row", + justify = "flex-end", + style, + ...props + }, + ref, + ) => { + return ( + + {children} + + ); + }, +); + +ModalFooter.displayName = "ModalFooter"; + +// Modal close trigger primitive +export interface ModalCloseProps extends TouchableOpacityProps { + onClose?: () => void; +} + +export const ModalClose = forwardRef< + React.ElementRef, + ModalCloseProps +>(({ children, onClose, onPress, ...props }, ref) => { + const handlePress = React.useCallback( + (event: GestureResponderEvent) => { + if (onClose) { + onClose(); + } + if (onPress) { + onPress(event); + } + }, + [onClose, onPress], + ); + + return ( + + {children} + + ); +}); + +ModalClose.displayName = "ModalClose"; + +// Primitive styles (minimal, unstyled) +const primitiveStyles = StyleSheet.create({ + container: { + flex: 1, + }, + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.5)", + justifyContent: "center", + alignItems: "center", + padding: 16, + }, + content: { + backgroundColor: "white", + borderRadius: 8, + overflow: "hidden", + }, + contentCenter: { + alignSelf: "center", + }, + contentTop: { + alignSelf: "center", + marginTop: 0, + }, + contentBottom: { + alignSelf: "center", + marginTop: "auto", + }, + contentLeft: { + alignSelf: "flex-start", + marginRight: "auto", + }, + contentRight: { + alignSelf: "flex-end", + marginLeft: "auto", + }, + sizeSm: { + maxWidth: screenWidth * 0.4, + maxHeight: screenHeight * 0.6, + }, + sizeMd: { + maxWidth: screenWidth * 0.6, + maxHeight: screenHeight * 0.8, + }, + sizeLg: { + maxWidth: screenWidth * 0.8, + maxHeight: screenHeight * 0.9, + }, + sizeXl: { + maxWidth: screenWidth * 0.95, + maxHeight: screenHeight * 0.95, + }, + sizeFull: { + width: screenWidth, + height: screenHeight, + maxWidth: screenWidth, + maxHeight: screenHeight, + borderRadius: 0, + }, + header: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + headerBorder: { + borderBottomWidth: 1, + borderBottomColor: "#e5e7eb", + }, + body: { + flex: 1, + paddingHorizontal: 16, + paddingVertical: 12, + }, + footer: { + paddingHorizontal: 16, + paddingVertical: 12, + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + footerBorder: { + borderTopWidth: 1, + borderTopColor: "#e5e7eb", + }, +}); + +// Export primitive collection +export const ModalPrimitive = { + Root: ModalRoot, + Overlay: ModalOverlay, + Content: ModalContent, + Header: ModalHeader, + Body: ModalBody, + Footer: ModalFooter, + Close: ModalClose, +}; diff --git a/js/components/src/components/ui/primitives/text.tsx b/js/components/src/components/ui/primitives/text.tsx new file mode 100644 index 00000000..a13eb318 --- /dev/null +++ b/js/components/src/components/ui/primitives/text.tsx @@ -0,0 +1,491 @@ +import { createContext, forwardRef, useContext } from "react"; +import type { ColorValue as RNColorValue } from "react-native"; +import { + AnimatableNumericValue, + ColorValue, + OpaqueColorValue, + Platform, + Text as RNText, + TextProps as RNTextProps, + TextStyle, +} from "react-native"; +import { useTheme } from "../../../lib/theme/theme"; +import { typography, type Typography } from "../../../lib/theme/tokens"; + +// Text inheritance context +interface TextContextValue { + fontSize?: number; + fontWeight?: TextStyle["fontWeight"]; + color?: string | RNColorValue | OpaqueColorValue; + fontFamily?: string; + lineHeight?: number; + textAlign?: TextStyle["textAlign"]; + letterSpacing?: number; + textTransform?: TextStyle["textTransform"]; + textDecorationLine?: TextStyle["textDecorationLine"]; + fontStyle?: TextStyle["fontStyle"]; + opacity?: number | AnimatableNumericValue; +} + +const TextContext = createContext(null); + +// Text primitive props +export interface TextPrimitiveProps extends Omit { + // Typography variants + variant?: + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "body1" + | "body2" + | "caption" + | "overline" + | "subtitle1" + | "subtitle2"; + + // Size system + size?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | number; + + // Weight system + weight?: + | "thin" + | "light" + | "normal" + | "medium" + | "semibold" + | "bold" + | "extrabold" + | "black"; + + // Color variants + color?: + | "default" + | "muted" + | "primary" + | "secondary" + | "destructive" + | "success" + | "warning" + | (string & {}); + + // Text alignment + align?: "left" | "center" | "right" | "justify"; + + // Line height + leading?: "none" | "tight" | "snug" | "normal" | "relaxed" | "loose" | number; + + // Letter spacing + tracking?: + | "tighter" + | "tight" + | "normal" + | "wide" + | "wider" + | "widest" + | number; + + // Text transform + transform?: "none" | "capitalize" | "uppercase" | "lowercase"; + + // Text decoration + decoration?: "none" | "underline" | "line-through"; + + // Font style + italic?: boolean; + + // Opacity + opacity?: number; + + // Custom style + style?: TextStyle | TextStyle[]; + + // Inheritance - whether this component should inherit from parent context + inherit?: boolean; + + // Reset inheritance - start fresh context + reset?: boolean; +} + +// Size mapping +const sizeMap = { + xs: 12, + sm: 14, + base: 16, + lg: 18, + xl: 20, + "2xl": 24, + "3xl": 30, + "4xl": 36, +} as const; + +// Weight mapping +const weightMap = { + thin: "100", + light: "300", + normal: "400", + medium: "500", + semibold: "600", + bold: "700", + extrabold: "800", + black: "900", +} as const; + +// Line height mapping +const leadingMap = { + none: 1, + tight: 1.2, + snug: 1.3, + normal: 1.5, + relaxed: 1.7, + loose: 2, +} as const; + +// Letter spacing mapping +const trackingMap = { + tighter: -0.8, + tight: -0.4, + normal: 0, + wide: 0.4, + wider: 0.8, + widest: 1.6, +} as const; + +// Variant definitions (platform-aware) +const getVariantStyles = () => { + // get platform-specific typography + // iOS, Android, Web (Universal) + const typographicPlatform = ( + Platform.OS === "ios" + ? "ios" + : Platform.OS === "android" + ? "android" + : "universal" + ) as keyof Typography; + const platformTypography = typography[typographicPlatform] as Record< + string, + TextStyle + >; + + if (!platformTypography) { + throw new Error("Platform typography not defined"); + } + + // Define mapping based on platform + if (typographicPlatform === "ios") { + return { + h1: platformTypography.largeTitle, + h2: platformTypography.title1, + h3: platformTypography.title2, + h4: platformTypography.title3, + h5: platformTypography.headline, + h6: platformTypography.headline, + subtitle1: platformTypography.subhead, + subtitle2: platformTypography.footnote, + body1: platformTypography.body, + body2: platformTypography.callout, + caption: platformTypography.caption1, + overline: platformTypography.caption2, + }; + } else if (typographicPlatform === "android") { + return { + h1: platformTypography.headline1, + h2: platformTypography.headline2, + h3: platformTypography.headline3, + h4: platformTypography.headline4, + h5: platformTypography.headline5, + h6: platformTypography.headline6, + subtitle1: platformTypography.subtitle1, + subtitle2: platformTypography.subtitle2, + body1: platformTypography.body1, + body2: platformTypography.body2, + caption: platformTypography.caption, + overline: platformTypography.overline, + }; + } else { + // universal + // Map variants to universal sizes + return { + h1: platformTypography["4xl"], + h2: platformTypography["3xl"], + h3: platformTypography["2xl"], + h4: platformTypography["xl"], + h5: platformTypography["lg"], + h6: platformTypography["base"], + subtitle1: platformTypography.base, + subtitle2: platformTypography.sm, + body1: platformTypography.base, + body2: platformTypography.sm, + caption: platformTypography.xs, + overline: platformTypography.xs, + }; + } +}; + +// Text root primitive +export const TextRoot = forwardRef( + ( + { + variant, + size, + weight, + color, + align, + leading, + tracking, + transform, + decoration, + italic = false, + opacity, + style, + inherit = true, + reset = false, + children, + ...props + }, + ref, + ) => { + const { theme } = useTheme(); + const parentContext = useContext(TextContext); + + // Get variant styles + const variantStyles = getVariantStyles() as Record; + + // Calculate inherited values + const inheritedContext = + inherit && !reset && parentContext ? parentContext : {}; + + // Calculate final styles + const finalStyles: TextStyle = { + // Start with inherited values + fontSize: inheritedContext.fontSize, + fontWeight: inheritedContext.fontWeight, + //color: inheritedContext.color, + fontFamily: inheritedContext.fontFamily, + lineHeight: inheritedContext.lineHeight, + textAlign: inheritedContext.textAlign, + letterSpacing: inheritedContext.letterSpacing, + textTransform: inheritedContext.textTransform, + textDecorationLine: + inheritedContext.textDecorationLine as TextStyle["textDecorationLine"], + fontStyle: inheritedContext.fontStyle, + opacity: inheritedContext.opacity, + + // Apply variant styles (these may override inherited) + ...(variant && variantStyles[variant]), + + // Apply explicit prop styles (these should override inherited and variant) + + // Apply size + ...(size && { + fontSize: typeof size === "number" ? size : sizeMap[size], + }), + + // Apply weight + ...(weight && { + fontWeight: weightMap[weight] as TextStyle["fontWeight"], + }), + + // Apply color + ...(color + ? { + color: + color === "default" + ? theme.colors.text + : color === "muted" + ? theme.colors.textMuted + : color === "primary" + ? theme.colors.primary + : color === "secondary" + ? theme.colors.secondary + : color === "destructive" + ? theme.colors.destructive + : color === "success" + ? theme.colors.success + : color === "warning" + ? theme.colors.warning + : color || inheritedContext.color, // Custom color string + } + : { color: inheritedContext.color || theme.colors.text }), + + // Apply alignment + ...(align && { + textAlign: align, + }), + + // Apply line height + ...(leading && { + lineHeight: typeof leading === "number" ? leading : leadingMap[leading], + }), + + // Apply letter spacing + ...(tracking && { + letterSpacing: + typeof tracking === "number" ? tracking : trackingMap[tracking], + }), + + // Apply text transform + ...(transform && + transform !== "none" && { + textTransform: transform, + }), + + // Apply text decoration + ...(decoration && + decoration !== "none" && { + textDecorationLine: decoration, + }), + + // Apply italic + ...(italic && { + fontStyle: "italic", + }), + + // Apply opacity + ...(opacity !== undefined && { + opacity, + }), + }; + + finalStyles.color = finalStyles.color as ColorValue; + + // Create context value for children + const contextValue: TextContextValue = { + fontSize: + typeof finalStyles.fontSize === "number" + ? finalStyles.fontSize + : undefined, + fontWeight: finalStyles.fontWeight, + color: finalStyles.color || undefined, + fontFamily: + typeof finalStyles.fontFamily === "string" + ? finalStyles.fontFamily + : undefined, + lineHeight: + typeof finalStyles.lineHeight === "number" + ? finalStyles.lineHeight + : undefined, + textAlign: finalStyles.textAlign, + letterSpacing: finalStyles.letterSpacing as number | undefined, + textTransform: finalStyles.textTransform, + textDecorationLine: + finalStyles.textDecorationLine as TextStyle["textDecorationLine"], + fontStyle: finalStyles.fontStyle, + opacity: finalStyles.opacity as number | undefined, + }; + + return ( + + + {children} + + + ); + }, +); + +TextRoot.displayName = "TextRoot"; + +// Text span primitive (inherits from parent but doesn't create new context) +export const TextSpan = forwardRef>( + ({ children, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +TextSpan.displayName = "TextSpan"; + +// Text block primitive (always creates new context) +export const TextBlock = forwardRef( + ({ children, reset = true, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +TextBlock.displayName = "TextBlock"; + +// Hook to access current text context +export function useTextContext(): TextContextValue | null { + return useContext(TextContext); +} + +// Utility function to create text styles +export function createTextStyle( + props: Omit, +): TextStyle { + // This is a utility function that can be used to generate styles + // without rendering a component + const style: TextStyle = {}; + + if (props.size) { + style.fontSize = + typeof props.size === "number" ? props.size : sizeMap[props.size]; + } + + if (props.weight) { + style.fontWeight = weightMap[props.weight] as TextStyle["fontWeight"]; + } + + if (props.align) { + style.textAlign = props.align; + } + + if (props.leading) { + style.lineHeight = + typeof props.leading === "number" + ? props.leading + : leadingMap[props.leading]; + } + + if (props.tracking) { + style.letterSpacing = + typeof props.tracking === "number" + ? props.tracking + : trackingMap[props.tracking]; + } + + if (props.transform && props.transform !== "none") { + style.textTransform = props.transform; + } + + if (props.decoration && props.decoration !== "none") { + style.textDecorationLine = props.decoration; + } + + if (props.italic) { + style.fontStyle = "italic"; + } + + if (props.opacity !== undefined) { + style.opacity = props.opacity; + } + + return style; +} + +// Export primitive collection +export const TextPrimitive: { + Root: typeof TextRoot; + Span: typeof TextSpan; + Block: typeof TextBlock; + Context: typeof TextContext; + useContext: typeof useTextContext; + createStyle: typeof createTextStyle; +} = { + Root: TextRoot, + Span: TextSpan, + Block: TextBlock, + Context: TextContext, + useContext: useTextContext, + createStyle: createTextStyle, +}; diff --git a/js/components/src/components/ui/text.tsx b/js/components/src/components/ui/text.tsx new file mode 100644 index 00000000..eaa0da1c --- /dev/null +++ b/js/components/src/components/ui/text.tsx @@ -0,0 +1,330 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import React, { forwardRef } from "react"; +import { StyleSheet } from "react-native"; +import { colors, borderRadius as radius, spacing } from "../../lib/theme/atoms"; +import { TextPrimitive, TextPrimitiveProps } from "./primitives/text"; + +// Text variants using class-variance-authority pattern +const textVariants = cva("", { + variants: { + variant: { + h1: "h1", + h2: "h2", + h3: "h3", + h4: "h4", + h5: "h5", + h6: "h6", + subtitle1: "subtitle1", + subtitle2: "subtitle2", + body1: "body1", + body2: "body2", + caption: "caption", + overline: "overline", + }, + size: { + xs: "xs", + sm: "sm", + base: "base", + lg: "lg", + xl: "xl", + "2xl": "2xl", + "3xl": "3xl", + "4xl": "4xl", + }, + weight: { + thin: "thin", + light: "light", + normal: "normal", + medium: "medium", + semibold: "semibold", + bold: "bold", + extrabold: "extrabold", + black: "black", + }, + color: { + default: "default", + muted: "muted", + primary: "primary", + secondary: "secondary", + destructive: "destructive", + success: "success", + warning: "warning", + }, + }, + defaultVariants: { + variant: "body1", + size: "base", + weight: "normal", + color: "default", + }, +}); + +export interface TextProps + extends Omit, + VariantProps { + // Additional convenience props + muted?: boolean; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + uppercase?: boolean; + lowercase?: boolean; + capitalize?: boolean; + center?: boolean; + right?: boolean; + justify?: boolean; + // Custom color override + customColor?: string; +} + +export const Text = forwardRef( + ( + { + variant = undefined, + size = undefined, + weight = undefined, + color = undefined, + muted = false, + bold = false, + italic = false, + underline = false, + strikethrough = false, + uppercase = false, + lowercase = false, + capitalize = false, + center = false, + right = false, + justify = false, + customColor, + style, + children, + ...props + }, + ref, + ) => { + // Create dynamic styles based on atoms + const styles = React.useMemo(() => createStyles(), []); + + // Override props based on convenience props + const finalColor = customColor ? customColor : muted ? "muted" : color; + + const finalTransform = uppercase + ? "uppercase" + : lowercase + ? "lowercase" + : capitalize + ? "capitalize" + : "none"; + + const finalDecoration = + underline && strikethrough + ? "underline line-through" + : underline + ? "underline" + : strikethrough + ? "line-through" + : "none"; + + const finalAlign = center + ? "center" + : right + ? "right" + : justify + ? "justify" + : "left"; + + // Get variant-specific styles + const variantStyle = styles[`${variant}Style` as keyof typeof styles] || {}; + + const styleArr = ( + Array.isArray(style) ? style : [style || undefined] + ).filter((s) => s !== undefined); + + return ( + + {children} + + ); + }, +); + +Text.displayName = "Text"; + +// Convenience components for common text elements +export const Heading = forwardRef< + any, + Omit & { level?: 1 | 2 | 3 | 4 | 5 | 6 } +>(({ level = 1, ...props }, ref) => ( + +)); + +Heading.displayName = "Heading"; + +export const Subtitle = forwardRef< + any, + Omit & { level?: 1 | 2 } +>(({ level = 1, ...props }, ref) => ( + +)); + +Subtitle.displayName = "Subtitle"; + +export const Body = forwardRef< + any, + Omit & { level?: 1 | 2 } +>(({ level = 1, ...props }, ref) => ( + +)); + +Body.displayName = "Body"; + +export const Caption = forwardRef>( + (props, ref) => , +); + +Caption.displayName = "Caption"; + +export const Label = forwardRef>( + (props, ref) => ( + + ), +); + +Label.displayName = "Label"; + +export const Code = forwardRef(({ style, ...props }, ref) => { + const styles = React.useMemo(() => createStyles(), []); + // if style is not an array, convert it to an array + const styleArr = (Array.isArray(style) ? style : [style || undefined]).filter( + (s) => s !== undefined, + ); + + return ; +}); + +Code.displayName = "Code"; + +// Span component for inline text styling (inherits from parent) +export const Span = forwardRef< + any, + Omit & { + variant?: + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "subtitle1" + | "subtitle2" + | "body1" + | "body2" + | "caption" + | "overline"; + size?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; + weight?: + | "thin" + | "light" + | "normal" + | "medium" + | "semibold" + | "bold" + | "extrabold" + | "black"; + color?: + | "default" + | "muted" + | "primary" + | "secondary" + | "destructive" + | "success" + | "warning"; + } +>(({ children, ...props }, ref) => ( + + {children} + +)); + +Span.displayName = "Span"; + +// Create atom-based styles +function createStyles() { + return StyleSheet.create({ + // Variant-specific styles + h1Style: { + marginBottom: spacing[4], + }, + h2Style: { + marginBottom: spacing[3], + }, + h3Style: { + marginBottom: spacing[3], + }, + h4Style: { + marginBottom: spacing[2], + }, + h5Style: { + marginBottom: spacing[2], + }, + h6Style: { + marginBottom: spacing[2], + }, + subtitle1Style: { + marginBottom: spacing[2], + }, + subtitle2Style: { + marginBottom: spacing[1], + }, + body1Style: { + marginBottom: spacing[3], + }, + body2Style: { + marginBottom: spacing[2], + }, + captionStyle: { + marginBottom: spacing[1], + }, + overlineStyle: { + marginBottom: spacing[1], + textTransform: "uppercase", + letterSpacing: 1, + }, + labelStyle: { + marginBottom: spacing[1], + }, + buttonStyle: { + textAlign: "center", + }, + codeStyle: { + fontFamily: "monospace", + backgroundColor: colors["muted"], + paddingHorizontal: spacing[1], + paddingVertical: 2, + borderRadius: radius.sm, + fontSize: 14, + }, + }); +} + +// Export text variants for external use +export { textVariants }; + +// Re-export primitive components for advanced usage +export { TextPrimitive }; diff --git a/js/components/src/components/ui/toast.tsx b/js/components/src/components/ui/toast.tsx new file mode 100644 index 00000000..8b578366 --- /dev/null +++ b/js/components/src/components/ui/toast.tsx @@ -0,0 +1,204 @@ +import { Portal } from "@rn-primitives/portal"; +import { useEffect, useState } from "react"; +import { + Animated, + Platform, + Pressable, + StyleSheet, + Text, + useWindowDimensions, + View, + ViewStyle, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useTheme } from "../../lib/theme/theme"; + +type ToastProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + actionLabel?: string; + onAction?: () => void; + duration?: number; // seconds +}; + +export function Toast({ + open, + onOpenChange, + title, + description, + actionLabel = "Action", + onAction, + duration = 3, +}: ToastProps) { + const [seconds, setSeconds] = useState(duration); + const insets = useSafeAreaInsets(); + const { theme } = useTheme(); + const [fadeAnim] = useState(new Animated.Value(0)); + const { width } = useWindowDimensions(); + const isWeb = Platform.OS === "web"; + const isDesktop = isWeb && width >= 768; + + const containerPosition: ViewStyle = isDesktop + ? { + top: undefined, + bottom: theme.spacing[4], + right: theme.spacing[4], // <-- use spacing, not 1 + alignItems: "flex-end", + minWidth: 400, + width: 400, + // Do NOT set left at all + } + : { + top: insets.top + theme.spacing[1], + left: 0, + right: 0, + alignItems: "center", + width: "100%", + maxWidth: undefined, + bottom: undefined, + }; + + useEffect(() => { + let interval: ReturnType | null = null; + + if (open) { + setSeconds(duration); + Animated.timing(fadeAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }).start(); + + interval = setInterval(() => { + setSeconds((prev) => { + if (prev <= 1) { + onOpenChange(false); + if (interval) clearInterval(interval); + return duration; + } + return prev - 1; + }); + }, 1000); + } else { + if (interval) clearInterval(interval); + Animated.timing(fadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }).start(); + setSeconds(duration); + } + + return () => { + if (interval) clearInterval(interval); + }; + // eslint-disable-next-line + }, [open, duration]); + + if (!open) return null; + + return ( + + + + + + {title} + + {description ? ( + + {description} + + ) : null} + + + {onAction && ( + + + {actionLabel} + + + )} + onOpenChange(false)} + > + Close + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: "absolute", + zIndex: 1000, + paddingHorizontal: 16, + }, + toast: { + opacity: 0.95, + borderWidth: 1, + gap: 8, + }, + button: { + borderWidth: 1, + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + backgroundColor: "transparent", + }, +}); diff --git a/js/components/src/components/ui/view.tsx b/js/components/src/components/ui/view.tsx new file mode 100644 index 00000000..c89a023f --- /dev/null +++ b/js/components/src/components/ui/view.tsx @@ -0,0 +1,344 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { forwardRef } from "react"; +import { + View as RNView, + ViewProps as RNViewProps, + ViewStyle, +} from "react-native"; +import { borderRadius as radius, spacing } from "../../lib/theme/atoms"; +import { useTheme } from "../../lib/theme/theme"; + +// View variants using class-variance-authority pattern +const viewVariants = cva("", { + variants: { + variant: { + default: "default", + card: "card", + overlay: "overlay", + surface: "surface", + container: "container", + }, + padding: { + none: "none", + xs: "xs", + sm: "sm", + md: "md", + lg: "lg", + xl: "xl", + }, + margin: { + none: "none", + xs: "xs", + sm: "sm", + md: "md", + lg: "lg", + xl: "xl", + }, + direction: { + row: "row", + column: "column", + "row-reverse": "row-reverse", + "column-reverse": "column-reverse", + }, + align: { + start: "start", + center: "center", + end: "end", + stretch: "stretch", + baseline: "baseline", + }, + justify: { + start: "start", + center: "center", + end: "end", + between: "between", + around: "around", + evenly: "evenly", + }, + flex: { + none: "none", + auto: "auto", + initial: "initial", + }, + }, + defaultVariants: { + variant: "default", + padding: "none", + margin: "none", + direction: "column", + align: "stretch", + justify: "start", + flex: "none", + }, +}); + +export interface ViewProps + extends Omit, + Omit, "flex"> { + // Style props + style?: ViewStyle | ViewStyle[]; + + // Convenience props + fullWidth?: boolean; + fullHeight?: boolean; + centered?: boolean; + + // Background + backgroundColor?: string; + + // Border + borderColor?: string; + borderWidth?: number; + borderRadius?: number; + + // Shadow (web only) + shadow?: boolean; + + // Custom flex values + flex?: number | "none" | "auto" | "initial"; +} + +export const View = forwardRef( + ( + { + variant = "default", + padding = "none", + margin = "none", + direction = "column", + align = "stretch", + justify = "start", + flex = "none", + fullWidth = false, + fullHeight = false, + centered = false, + backgroundColor, + borderColor, + borderWidth, + borderRadius, + shadow = false, + style, + ...props + }, + ref, + ) => { + const { theme } = useTheme(); + + // Map variant to styles + const variantStyles: ViewStyle = (() => { + switch (variant) { + case "card": + return { + backgroundColor: theme.colors.card, + borderRadius: radius.lg, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }; + case "overlay": + return { + backgroundColor: "rgba(0, 0, 0, 0.5)", + }; + case "surface": + return { + backgroundColor: theme.colors.background, + }; + case "container": + return { + backgroundColor: theme.colors.background, + padding: spacing[4], + }; + default: + return {}; + } + })(); + + // Map padding to numeric values + const paddingValue = (() => { + switch (padding) { + case "xs": + return spacing[1]; + case "sm": + return spacing[2]; + case "md": + return spacing[3]; + case "lg": + return spacing[4]; + case "xl": + return spacing[5]; + default: + return undefined; + } + })(); + + // Map margin to numeric values + const marginValue = (() => { + switch (margin) { + case "xs": + return spacing[1]; + case "sm": + return spacing[2]; + case "md": + return spacing[3]; + case "lg": + return spacing[4]; + case "xl": + return spacing[5]; + default: + return undefined; + } + })(); + + // Map flex direction + const flexDirection = (() => { + switch (direction) { + case "row": + return "row"; + case "column": + return "column"; + case "row-reverse": + return "row-reverse"; + case "column-reverse": + return "column-reverse"; + default: + return "column"; + } + })() as ViewStyle["flexDirection"]; + + // Map align items + const alignItems = (() => { + switch (align) { + case "start": + return "flex-start"; + case "center": + return "center"; + case "end": + return "flex-end"; + case "stretch": + return "stretch"; + case "baseline": + return "baseline"; + default: + return "stretch"; + } + })() as ViewStyle["alignItems"]; + + // Map justify content + const justifyContent = (() => { + switch (justify) { + case "start": + return "flex-start"; + case "center": + return "center"; + case "end": + return "flex-end"; + case "between": + return "space-between"; + case "around": + return "space-around"; + case "evenly": + return "space-evenly"; + default: + return "flex-start"; + } + })() as ViewStyle["justifyContent"]; + + // Map flex value + const flexValue = (() => { + if (typeof flex === "number") { + return flex; + } + switch (flex) { + case "auto": + return undefined; // auto is default + case "initial": + return 0; + case "none": + default: + return undefined; + } + })(); + + const computedStyle: ViewStyle = { + ...variantStyles, + ...(paddingValue !== undefined && { padding: paddingValue }), + ...(marginValue !== undefined && { margin: marginValue }), + flexDirection, + alignItems, + justifyContent, + ...(flexValue !== undefined && { flex: flexValue }), + ...(fullWidth && { width: "100%" }), + ...(fullHeight && { height: "100%" }), + ...(centered && { + alignItems: "center", + justifyContent: "center", + }), + ...(backgroundColor && { backgroundColor }), + ...(borderColor && { borderColor }), + ...(borderWidth !== undefined && { borderWidth }), + ...(borderRadius !== undefined && { borderRadius }), + ...(shadow && { + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 3, + }), + }; + + const finalStyle = Array.isArray(style) + ? [computedStyle, ...style] + : [computedStyle, style]; + + return ; + }, +); + +View.displayName = "View"; + +// Convenience components +export const Card = forwardRef>( + (props, ref) => , +); + +Card.displayName = "Card"; + +export const Container = forwardRef>( + (props, ref) => , +); + +Container.displayName = "Container"; + +export const Surface = forwardRef>( + (props, ref) => , +); + +Surface.displayName = "Surface"; + +export const Overlay = forwardRef>( + (props, ref) => , +); + +Overlay.displayName = "Overlay"; + +export const Row = forwardRef>( + (props, ref) => , +); + +Row.displayName = "Row"; + +export const Column = forwardRef>( + (props, ref) => , +); + +Column.displayName = "Column"; + +export const Center = forwardRef((props, ref) => ( + +)); + +Center.displayName = "Center"; + +// Export view variants for external use +export { viewVariants }; diff --git a/js/components/src/lib/theme/atoms.ts b/js/components/src/lib/theme/atoms.ts new file mode 100644 index 00000000..f8af7ca5 --- /dev/null +++ b/js/components/src/lib/theme/atoms.ts @@ -0,0 +1,746 @@ +/** + * Theme atoms - Enhanced exports with pairify function for array-style syntax + * These provide direct access to static design tokens and support composition + */ + +import { Platform } from "react-native"; +import { + animations, + borderRadius, + colors as rawColors, + shadows, + spacing, + touchTargets, + typography, +} from "./tokens"; + +// Type for style objects that can be spread +type StyleValue = Record; + +/** + * Pairify function - converts nested objects into key-value pairs that return style objects + * This allows for array-style syntax like style={[a.borders.green[300], a.shadows.xl]} + */ +function pairify>( + obj: T, + styleKeyPrefix: string, +): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + // For nested objects (like color scales), create another level + result[key] = {}; + for (const [nestedKey, nestedValue] of Object.entries(value)) { + result[key][nestedKey] = { [styleKeyPrefix]: nestedValue }; + } + } else { + // For simple values, create the style object directly + result[key] = { [styleKeyPrefix]: value }; + } + } + + return result as Record; +} + +/** + * Create pairified style atoms for easy composition + */ + +// Re-export static design tokens that don't change with theme +export { animations, borderRadius, shadows, spacing, touchTargets }; + +// Export raw color tokens for advanced use cases +export const colors = rawColors; + +// Platform-aware typography helper +export const getPlatformTypography = () => { + if (Platform.OS === "ios") { + return typography.ios; + } else if (Platform.OS === "android") { + return typography.android; + } + return typography.universal; +}; + +// Export all typography scales +export const typographyAtoms = { + platform: getPlatformTypography(), + universal: typography.universal, + ios: typography.ios, + android: typography.android, +}; + +// Static icon sizes (colors are handled by theme) +export const iconSizes = { + sm: 16, + md: 20, + lg: 24, + xl: 32, +}; + +// Common layout utilities +export const layout = { + flex: { + center: { + justifyContent: "center" as const, + alignItems: "center" as const, + }, + row: { + flexDirection: "row" as const, + }, + column: { + flexDirection: "column" as const, + }, + spaceBetween: { + justifyContent: "space-between" as const, + }, + spaceAround: { + justifyContent: "space-around" as const, + }, + spaceEvenly: { + justifyContent: "space-evenly" as const, + }, + }, + position: { + absolute: { + position: "absolute" as const, + }, + relative: { + position: "relative" as const, + }, + }, +}; + +// Enhanced border utilities with pairified colors and widths +export const borders = { + width: pairify( + { + thin: 1, + medium: 2, + thick: 4, + }, + "borderWidth", + ), + + style: { + solid: { borderStyle: "solid" as const }, + dashed: { borderStyle: "dashed" as const }, + dotted: { borderStyle: "dotted" as const }, + }, + + // Pairified color borders + color: pairify(rawColors, "borderColor"), + + // Top border utilities + top: { + width: pairify( + { + thin: 1, + medium: 2, + thick: 4, + }, + "borderTopWidth", + ), + color: pairify(rawColors, "borderTopColor"), + }, + + // Bottom border utilities + bottom: { + width: pairify( + { + thin: 1, + medium: 2, + thick: 4, + }, + "borderBottomWidth", + ), + color: pairify(rawColors, "borderBottomColor"), + }, + + // Left border utilities + left: { + width: pairify( + { + thin: 1, + medium: 2, + thick: 4, + }, + "borderLeftWidth", + ), + color: pairify(rawColors, "borderLeftColor"), + }, + + // Right border utilities + right: { + width: pairify( + { + thin: 1, + medium: 2, + thick: 4, + }, + "borderRightWidth", + ), + color: pairify(rawColors, "borderRightColor"), + }, +}; + +// Pairified spacing utilities +export const spacingAtoms = { + margin: pairify(spacing, "margin"), + marginTop: pairify(spacing, "marginTop"), + marginRight: pairify(spacing, "marginRight"), + marginBottom: pairify(spacing, "marginBottom"), + marginLeft: pairify(spacing, "marginLeft"), + marginHorizontal: pairify(spacing, "marginHorizontal"), + marginVertical: pairify(spacing, "marginVertical"), + + padding: pairify(spacing, "padding"), + paddingTop: pairify(spacing, "paddingTop"), + paddingRight: pairify(spacing, "paddingRight"), + paddingBottom: pairify(spacing, "paddingBottom"), + paddingLeft: pairify(spacing, "paddingLeft"), + paddingHorizontal: pairify(spacing, "paddingHorizontal"), + paddingVertical: pairify(spacing, "paddingVertical"), +}; + +// Pairified border radius utilities +export const radiusAtoms = { + all: pairify(borderRadius, "borderRadius"), + top: pairify(borderRadius, "borderTopLeftRadius"), + topRight: pairify(borderRadius, "borderTopRightRadius"), + bottom: pairify(borderRadius, "borderBottomLeftRadius"), + bottomRight: pairify(borderRadius, "borderBottomRightRadius"), + left: pairify(borderRadius, "borderTopLeftRadius"), + right: pairify(borderRadius, "borderTopRightRadius"), +}; + +// Background color utilities +export const backgrounds = pairify(rawColors, "backgroundColor"); + +// Text color utilities +export const textColors = pairify(rawColors, "color"); + +// Size utilities (width and height) +export const sizes = { + width: pairify(spacing, "width"), + height: pairify(spacing, "height"), + minWidth: pairify(spacing, "minWidth"), + minHeight: pairify(spacing, "minHeight"), + maxWidth: pairify(spacing, "maxWidth"), + maxHeight: pairify(spacing, "maxHeight"), +}; + +// Percentage-based sizes +const percentageSizes = { + "10": "10%", + "20": "20%", + "25": "25%", + "30": "30%", + "33": "33.333333%", + "40": "40%", + "50": "50%", + "60": "60%", + "66": "66.666667%", + "70": "70%", + "75": "75%", + "80": "80%", + "90": "90%", + "100": "100%", +} as const; + +export const percentSizes = { + width: pairify(percentageSizes, "width"), + height: pairify(percentageSizes, "height"), + minWidth: pairify(percentageSizes, "minWidth"), + minHeight: pairify(percentageSizes, "minHeight"), + maxWidth: pairify(percentageSizes, "maxWidth"), + maxHeight: pairify(percentageSizes, "maxHeight"), +}; + +// Flex utilities +export const flex = { + values: pairify( + { + 0: 0, + 1: 1, + 2: 2, + 3: 3, + 4: 4, + 5: 5, + }, + "flex", + ), + + grow: pairify( + { + 0: 0, + 1: 1, + }, + "flexGrow", + ), + + shrink: pairify( + { + 0: 0, + 1: 1, + }, + "flexShrink", + ), + + basis: { + ...pairify(spacing, "flexBasis"), + ...pairify(percentageSizes, "flexBasis"), + auto: { flexBasis: "auto" }, + }, +}; + +// Opacity utilities +export const opacity = pairify( + { + 0: 0, + 5: 0.05, + 10: 0.1, + 20: 0.2, + 25: 0.25, + 30: 0.3, + 40: 0.4, + 50: 0.5, + 60: 0.6, + 70: 0.7, + 75: 0.75, + 80: 0.8, + 90: 0.9, + 95: 0.95, + 100: 1, + }, + "opacity", +); + +// Z-index utilities +export const zIndex = pairify( + { + 0: 0, + 10: 10, + 20: 20, + 30: 30, + 40: 40, + 50: 50, + auto: "auto", + }, + "zIndex", +); + +// Overflow utilities +export const overflow = { + visible: { overflow: "visible" as const }, + hidden: { overflow: "hidden" as const }, + scroll: { overflow: "scroll" as const }, +}; + +// Text alignment utilities +export const textAlign = { + left: { textAlign: "left" as const }, + center: { textAlign: "center" as const }, + right: { textAlign: "right" as const }, + justify: { textAlign: "justify" as const }, + auto: { textAlign: "auto" as const }, +}; + +// Font weight utilities +export const fontWeight = pairify( + { + thin: "100", + extralight: "200", + light: "300", + normal: "400", + medium: "500", + semibold: "600", + bold: "700", + extrabold: "800", + black: "900", + }, + "fontWeight", +); + +// Font size utilities (separate from typography for quick access) +export const fontSize = pairify( + { + xs: 12, + sm: 14, + base: 16, + lg: 18, + xl: 20, + "2xl": 24, + "3xl": 30, + "4xl": 36, + "5xl": 48, + "6xl": 60, + "7xl": 72, + "8xl": 96, + "9xl": 128, + }, + "fontSize", +); + +// Line height utilities +export const lineHeight = pairify( + { + none: 1, + tight: 1.25, + snug: 1.375, + normal: 1.5, + relaxed: 1.625, + loose: 2, + 3: 12, + 4: 16, + 5: 20, + 6: 24, + 7: 28, + 8: 32, + 9: 36, + 10: 40, + }, + "lineHeight", +); + +// Letter spacing utilities +export const letterSpacing = pairify( + { + tighter: -0.5, + tight: -0.25, + normal: 0, + wide: 0.25, + wider: 0.5, + widest: 1, + }, + "letterSpacing", +); + +// Text transform utilities +export const textTransform = { + uppercase: { textTransform: "uppercase" as const }, + lowercase: { textTransform: "lowercase" as const }, + capitalize: { textTransform: "capitalize" as const }, + none: { textTransform: "none" as const }, +}; + +// Text decoration utilities +export const textDecoration = { + none: { textDecorationLine: "none" as const }, + underline: { textDecorationLine: "underline" as const }, + lineThrough: { textDecorationLine: "line-through" as const }, + underlineLineThrough: { + textDecorationLine: "underline line-through" as const, + }, +}; + +// Text align vertical utilities (React Native specific) +export const textAlignVertical = { + auto: { textAlignVertical: "auto" as const }, + top: { textAlignVertical: "top" as const }, + bottom: { textAlignVertical: "bottom" as const }, + center: { textAlignVertical: "center" as const }, +}; + +// Transform utilities +export const transforms = { + rotate: pairify( + { + 0: 0, + 1: 1, + 2: 2, + 3: 3, + 6: 6, + 12: 12, + 45: 45, + 90: 90, + 180: 180, + 270: 270, + }, + "rotate", + ), + + scale: pairify( + { + 0: 0, + 50: 0.5, + 75: 0.75, + 90: 0.9, + 95: 0.95, + 100: 1, + 105: 1.05, + 110: 1.1, + 125: 1.25, + 150: 1.5, + 200: 2, + }, + "scale", + ), + + scaleX: pairify( + { + 0: 0, + 50: 0.5, + 75: 0.75, + 90: 0.9, + 95: 0.95, + 100: 1, + 105: 1.05, + 110: 1.1, + 125: 1.25, + 150: 1.5, + 200: 2, + }, + "scaleX", + ), + + scaleY: pairify( + { + 0: 0, + 50: 0.5, + 75: 0.75, + 90: 0.9, + 95: 0.95, + 100: 1, + 105: 1.05, + 110: 1.1, + 125: 1.25, + 150: 1.5, + 200: 2, + }, + "scaleY", + ), + + translateX: pairify(spacing, "translateX"), + translateY: pairify(spacing, "translateY"), +}; + +// Absolute positioning utilities +export const position = { + top: pairify(spacing, "top"), + right: pairify(spacing, "right"), + bottom: pairify(spacing, "bottom"), + left: pairify(spacing, "left"), + + // Common position combinations + topLeft: (top: number, left: number) => ({ + position: "absolute" as const, + top, + left, + }), + topRight: (top: number, right: number) => ({ + position: "absolute" as const, + top, + right, + }), + bottomLeft: (bottom: number, left: number) => ({ + position: "absolute" as const, + bottom, + left, + }), + bottomRight: (bottom: number, right: number) => ({ + position: "absolute" as const, + bottom, + right, + }), + + // Percentage-based positioning + percent: { + top: pairify( + { + 0: "0%", + 25: "25%", + 50: "50%", + 75: "75%", + 100: "100%", + }, + "top", + ), + right: pairify( + { + 0: "0%", + 25: "25%", + 50: "50%", + 75: "75%", + 100: "100%", + }, + "right", + ), + bottom: pairify( + { + 0: "0%", + 25: "25%", + 50: "50%", + 75: "75%", + 100: "100%", + }, + "bottom", + ), + left: pairify( + { + 0: "0%", + 25: "25%", + 50: "50%", + 75: "75%", + 100: "100%", + }, + "left", + ), + }, +}; + +// Aspect ratio utilities (React Native 0.71+) +export const aspectRatio = pairify( + { + square: 1, + video: 16 / 9, + photo: 4 / 3, + portrait: 3 / 4, + wide: 21 / 9, + ultrawide: 32 / 9, + "1/1": 1, + "3/2": 3 / 2, + "4/3": 4 / 3, + "16/9": 16 / 9, + "21/9": 21 / 9, + }, + "aspectRatio", +); + +// Gap utilities (React Native 0.71+) +export const gap = { + row: pairify(spacing, "rowGap"), + column: pairify(spacing, "columnGap"), + all: pairify(spacing, "gap"), +}; + +// Common layout patterns +export const layouts = { + // Full screen + fullScreen: { + position: "absolute" as const, + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + + // Centered content + centered: { + flex: 1, + justifyContent: "center" as const, + alignItems: "center" as const, + }, + + // Centered modal/overlay + overlay: { + position: "absolute" as const, + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: "center" as const, + alignItems: "center" as const, + backgroundColor: "rgba(0, 0, 0, 0.5)", + }, + + // Safe area friendly + safeContainer: { + flex: 1, + paddingTop: Platform.OS === "ios" ? 44 : 0, // Status bar height + }, + + // Row with space between + spaceBetweenRow: { + flexDirection: "row" as const, + justifyContent: "space-between" as const, + alignItems: "center" as const, + }, + + // Sticky header + stickyHeader: { + position: "absolute" as const, + top: 0, + left: 0, + right: 0, + zIndex: 10, + }, + + // Bottom sheet style + bottomSheet: { + position: "absolute" as const, + bottom: 0, + left: 0, + right: 0, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + }, +}; + +// Export everything as a combined atoms object for convenience +export const atoms = { + colors: rawColors, + spacing, + borderRadius, + radius: radiusAtoms, + typography: typographyAtoms, + shadows, + touchTargets, + animations, + iconSizes, + layout, + borders, + backgrounds, + textColors, + spacingAtoms, + sizes, + percentSizes, + flex, + opacity, + zIndex, + overflow, + textAlign, + fontWeight, + fontSize, + lineHeight, + letterSpacing, + textTransform, + textDecoration, + textAlignVertical, + transforms, + position, + aspectRatio, + gap, + layouts, +}; + +// Convenient shorthand aliases +export const a = atoms; +export const bg = backgrounds; +export const text = textColors; +export const m = spacingAtoms.margin; +export const mt = spacingAtoms.marginTop; +export const mr = spacingAtoms.marginRight; +export const mb = spacingAtoms.marginBottom; +export const ml = spacingAtoms.marginLeft; +export const mx = spacingAtoms.marginHorizontal; +export const my = spacingAtoms.marginVertical; +export const p = spacingAtoms.padding; +export const pt = spacingAtoms.paddingTop; +export const pr = spacingAtoms.paddingRight; +export const pb = spacingAtoms.paddingBottom; +export const pl = spacingAtoms.paddingLeft; +export const px = spacingAtoms.paddingHorizontal; +export const py = spacingAtoms.paddingVertical; +export const w = sizes.width; +export const h = sizes.height; +export const r = radiusAtoms.all; +export const top = position.top; +export const right = position.right; +export const bottom = position.bottom; +export const left = position.left; +export const rotate = transforms.rotate; +export const scale = transforms.scale; +export const translateX = transforms.translateX; +export const translateY = transforms.translateY; diff --git a/js/components/src/lib/theme/atoms.types.ts b/js/components/src/lib/theme/atoms.types.ts new file mode 100644 index 00000000..49b0ef54 --- /dev/null +++ b/js/components/src/lib/theme/atoms.types.ts @@ -0,0 +1,268 @@ +/** + * Type definitions for enhanced atoms with pairify function + * Provides comprehensive type safety for array-style syntax + */ + +import type { TextStyle, ViewStyle } from "react-native"; +import type { BorderRadius, Colors, Spacing } from "./tokens"; + +// Base style value type +export type StyleValue = ViewStyle | TextStyle; + +// Pairified structure types +export type PairifiedSpacing = { + [K in keyof Spacing]: StyleValue; +}; + +export type PairifiedColors = { + [K in keyof Colors]: K extends string + ? Colors[K] extends Record + ? { [N in keyof Colors[K]]: StyleValue } + : StyleValue + : never; +}; + +export type PairifiedBorderRadius = { + [K in keyof BorderRadius]: StyleValue; +}; + +// Border utilities types +export type BorderWidthAtoms = { + thin: StyleValue; + medium: StyleValue; + thick: StyleValue; +}; + +export type BorderStyleAtoms = { + solid: StyleValue; + dashed: StyleValue; + dotted: StyleValue; +}; + +export type BorderSideAtoms = { + width: BorderWidthAtoms; + color: PairifiedColors; +}; + +export type BorderAtoms = { + width: BorderWidthAtoms; + style: BorderStyleAtoms; + color: PairifiedColors; + top: BorderSideAtoms; + bottom: BorderSideAtoms; + left: BorderSideAtoms; + right: BorderSideAtoms; +}; + +// Spacing atoms types +export type SpacingAtoms = { + margin: PairifiedSpacing; + marginTop: PairifiedSpacing; + marginRight: PairifiedSpacing; + marginBottom: PairifiedSpacing; + marginLeft: PairifiedSpacing; + marginHorizontal: PairifiedSpacing; + marginVertical: PairifiedSpacing; + padding: PairifiedSpacing; + paddingTop: PairifiedSpacing; + paddingRight: PairifiedSpacing; + paddingBottom: PairifiedSpacing; + paddingLeft: PairifiedSpacing; + paddingHorizontal: PairifiedSpacing; + paddingVertical: PairifiedSpacing; +}; + +// Border radius atoms types +export type RadiusAtoms = { + all: PairifiedBorderRadius; + top: PairifiedBorderRadius; + topRight: PairifiedBorderRadius; + bottom: PairifiedBorderRadius; + bottomRight: PairifiedBorderRadius; + left: PairifiedBorderRadius; + right: PairifiedBorderRadius; +}; + +// Size utilities types +export type SizeAtoms = { + width: PairifiedSpacing; + height: PairifiedSpacing; + minWidth: PairifiedSpacing; + minHeight: PairifiedSpacing; + maxWidth: PairifiedSpacing; + maxHeight: PairifiedSpacing; +}; + +export type PercentSizeValues = { + 10: StyleValue; + 20: StyleValue; + 25: StyleValue; + 30: StyleValue; + 33: StyleValue; + 40: StyleValue; + 50: StyleValue; + 60: StyleValue; + 66: StyleValue; + 70: StyleValue; + 75: StyleValue; + 80: StyleValue; + 90: StyleValue; + 100: StyleValue; +}; + +export type PercentSizeAtoms = { + width: PercentSizeValues; + height: PercentSizeValues; + minWidth: PercentSizeValues; + minHeight: PercentSizeValues; + maxWidth: PercentSizeValues; + maxHeight: PercentSizeValues; +}; + +// Flex utilities types +export type FlexValues = { + 0: StyleValue; + 1: StyleValue; + 2: StyleValue; + 3: StyleValue; + 4: StyleValue; + 5: StyleValue; +}; + +export type FlexGrowShrinkValues = { + 0: StyleValue; + 1: StyleValue; +}; + +export type FlexAtoms = { + values: FlexValues; + grow: FlexGrowShrinkValues; + shrink: FlexGrowShrinkValues; + basis: PairifiedSpacing & PercentSizeValues & { auto: StyleValue }; +}; + +// Opacity utilities types +export type OpacityAtoms = { + 0: StyleValue; + 5: StyleValue; + 10: StyleValue; + 20: StyleValue; + 25: StyleValue; + 30: StyleValue; + 40: StyleValue; + 50: StyleValue; + 60: StyleValue; + 70: StyleValue; + 75: StyleValue; + 80: StyleValue; + 90: StyleValue; + 95: StyleValue; + 100: StyleValue; +}; + +// Z-index utilities types +export type ZIndexAtoms = { + 0: StyleValue; + 10: StyleValue; + 20: StyleValue; + 30: StyleValue; + 40: StyleValue; + 50: StyleValue; + auto: StyleValue; +}; + +// Overflow utilities types +export type OverflowAtoms = { + visible: StyleValue; + hidden: StyleValue; + scroll: StyleValue; +}; + +// Text alignment utilities types +export type TextAlignAtoms = { + left: StyleValue; + center: StyleValue; + right: StyleValue; + justify: StyleValue; + auto: StyleValue; +}; + +// Font weight utilities types +export type FontWeightAtoms = { + thin: StyleValue; + extralight: StyleValue; + light: StyleValue; + normal: StyleValue; + medium: StyleValue; + semibold: StyleValue; + bold: StyleValue; + extrabold: StyleValue; + black: StyleValue; +}; + +// Layout utilities types +export type LayoutAtoms = { + flex: { + center: StyleValue; + row: StyleValue; + column: StyleValue; + spaceBetween: StyleValue; + spaceAround: StyleValue; + spaceEvenly: StyleValue; + }; + position: { + absolute: StyleValue; + relative: StyleValue; + }; +}; + +// Icon sizes types +export type IconSizeAtoms = { + sm: number; + md: number; + lg: number; + xl: number; +}; + +// Typography atoms types +export type TypographyAtoms = { + platform: Record; + universal: Record; + ios: Record; + android: Record; +}; + +// Main atoms object type +export type Atoms = { + colors: Colors; + spacing: typeof import("./tokens").spacing; + borderRadius: typeof import("./tokens").borderRadius; + radius: RadiusAtoms; + typography: TypographyAtoms; + shadows: typeof import("./tokens").shadows; + touchTargets: typeof import("./tokens").touchTargets; + animations: typeof import("./tokens").animations; + iconSizes: IconSizeAtoms; + layout: LayoutAtoms; + borders: BorderAtoms; + backgrounds: PairifiedColors; + textColors: PairifiedColors; + spacingAtoms: SpacingAtoms; + sizes: SizeAtoms; + percentSizes: PercentSizeAtoms; + flex: FlexAtoms; + opacity: OpacityAtoms; + zIndex: ZIndexAtoms; + overflow: OverflowAtoms; + textAlign: TextAlignAtoms; + fontWeight: FontWeightAtoms; +}; + +// Shorthand aliases types +export type BackgroundAtoms = PairifiedColors; +export type TextColorAtoms = PairifiedColors; +export type MarginAtoms = PairifiedSpacing; +export type PaddingAtoms = PairifiedSpacing; +export type WidthAtoms = PairifiedSpacing; +export type HeightAtoms = PairifiedSpacing; +export type BorderRadiusAllAtoms = PairifiedBorderRadius; diff --git a/js/components/src/lib/theme/index.ts b/js/components/src/lib/theme/index.ts new file mode 100644 index 00000000..89b9f201 --- /dev/null +++ b/js/components/src/lib/theme/index.ts @@ -0,0 +1,48 @@ +// Main theme system exports +export { + ThemeProvider, + createThemeColors, + createThemeIcons, + createThemeStyles, + createThemedStyles, + darkTheme, + lightTheme, + usePlatformTypography, + useTheme, + type Theme, + type ThemeIcons, + type ThemeStyles, +} from "./theme"; + +// Design tokens +export { + animations, + borderRadius, + breakpoints, + colors, + shadows, + spacing, + touchTargets, + typography, + type Animations, + type BorderRadius, + type Breakpoints, + type Colors, + type Shadows, + type Spacing, + type TouchTargets, + type Typography, +} from "./tokens"; + +// Utility atoms +export { + atoms, + borders, + getPlatformTypography, + iconSizes, + layout, + typographyAtoms, +} from "./atoms"; + +// Convenience re-exports +export * as tokens from "./tokens"; diff --git a/js/components/src/lib/theme/theme.tsx b/js/components/src/lib/theme/theme.tsx new file mode 100644 index 00000000..bc007800 --- /dev/null +++ b/js/components/src/lib/theme/theme.tsx @@ -0,0 +1,428 @@ +import { + createContext, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; +import { Platform, useColorScheme } from "react-native"; +import { + animations, + borderRadius, + colors, + shadows, + spacing, + touchTargets, + typography, +} from "./tokens"; + +// Theme interfaces +export interface Theme { + colors: { + // Core semantic colors + background: string; + foreground: string; + + // Card/surface colors + card: string; + cardForeground: string; + + // Popover colors + popover: string; + popoverForeground: string; + + // Primary colors + primary: string; + primaryForeground: string; + + // Secondary colors + secondary: string; + secondaryForeground: string; + + // Muted colors + muted: string; + mutedForeground: string; + + // Accent colors + accent: string; + accentForeground: string; + + // Destructive colors + destructive: string; + destructiveForeground: string; + + // Success colors + success: string; + successForeground: string; + + // Warning colors + warning: string; + warningForeground: string; + + // Border and input colors + border: string; + input: string; + ring: string; + + // Text colors + text: string; + textMuted: string; + textDisabled: string; + }; + spacing: typeof spacing; + borderRadius: typeof borderRadius; + typography: typeof typography; + shadows: typeof shadows; + touchTargets: typeof touchTargets; + animations: typeof animations; +} + +// Utility styles interface +export interface ThemeStyles { + shadow: { + sm: typeof shadows.sm; + md: typeof shadows.md; + lg: typeof shadows.lg; + xl: typeof shadows.xl; + }; + button: { + primary: object; + secondary: object; + outline: object; + ghost: object; + }; + text: { + primary: object; + muted: object; + disabled: object; + }; + input: { + base: object; + focused: object; + error: object; + }; + card: { + base: object; + }; +} + +// Icon utilities interface +export interface ThemeIcons { + color: { + default: string; + muted: string; + primary: string; + secondary: string; + destructive: string; + success: string; + warning: string; + }; + size: { + sm: number; + md: number; + lg: number; + xl: number; + }; +} + +// Create theme colors based on dark mode +const createThemeColors = (isDark: boolean): Theme["colors"] => ({ + background: isDark ? colors.gray[950] : colors.white, + foreground: isDark ? colors.gray[50] : colors.gray[950], + + card: isDark ? colors.gray[900] : colors.white, + cardForeground: isDark ? colors.gray[50] : colors.gray[950], + + popover: isDark ? colors.gray[900] : colors.white, + popoverForeground: isDark ? colors.gray[50] : colors.gray[950], + + primary: Platform.OS === "ios" ? colors.ios.systemBlue : colors.primary[500], + primaryForeground: colors.white, + + secondary: isDark ? colors.gray[800] : colors.gray[100], + secondaryForeground: isDark ? colors.gray[50] : colors.gray[900], + + muted: isDark ? colors.gray[800] : colors.gray[100], + mutedForeground: isDark ? colors.gray[400] : colors.gray[500], + + accent: isDark ? colors.gray[800] : colors.gray[100], + accentForeground: isDark ? colors.gray[50] : colors.gray[900], + + destructive: + Platform.OS === "ios" ? colors.ios.systemRed : colors.destructive[500], + destructiveForeground: colors.white, + + success: Platform.OS === "ios" ? colors.ios.systemGreen : colors.success[500], + successForeground: colors.white, + + warning: + Platform.OS === "ios" ? colors.ios.systemOrange : colors.warning[500], + warningForeground: colors.white, + + border: isDark ? colors.gray[500] + "30" : colors.gray[200] + "30", + input: isDark ? colors.gray[800] : colors.gray[200], + ring: Platform.OS === "ios" ? colors.ios.systemBlue : colors.primary[500], + + text: isDark ? colors.gray[50] : colors.gray[950], + textMuted: isDark ? colors.gray[400] : colors.gray[500], + textDisabled: isDark ? colors.gray[600] : colors.gray[400], +}); + +// Create theme styles based on colors +const createThemeStyles = (themeColors: Theme["colors"]): ThemeStyles => ({ + shadow: { + sm: shadows.sm, + md: shadows.md, + lg: shadows.lg, + xl: shadows.xl, + }, + button: { + primary: { + backgroundColor: themeColors.primary, + borderWidth: 0, + ...shadows.sm, + }, + secondary: { + backgroundColor: themeColors.secondary, + borderWidth: 0, + }, + outline: { + backgroundColor: "transparent", + borderWidth: 1, + borderColor: themeColors.border, + }, + ghost: { + backgroundColor: "transparent", + borderWidth: 0, + }, + }, + text: { + primary: { + color: themeColors.text, + }, + muted: { + color: themeColors.textMuted, + }, + disabled: { + color: themeColors.textDisabled, + }, + }, + input: { + base: { + backgroundColor: themeColors.background, + borderWidth: 1, + borderColor: themeColors.border, + borderRadius: borderRadius.md, + paddingHorizontal: spacing[3], + paddingVertical: spacing[3], + minHeight: touchTargets.minimum, + }, + focused: { + borderColor: themeColors.ring, + borderWidth: 2, + }, + error: { + borderColor: themeColors.destructive, + borderWidth: 2, + }, + }, + card: { + base: { + backgroundColor: themeColors.card, + borderRadius: borderRadius.lg, + ...shadows.sm, + }, + }, +}); + +// Create theme icons based on colors +const createThemeIcons = (themeColors: Theme["colors"]): ThemeIcons => ({ + color: { + default: themeColors.text, + muted: themeColors.textMuted, + primary: themeColors.primary, + secondary: themeColors.secondary, + destructive: themeColors.destructive, + success: themeColors.success, + warning: themeColors.warning, + }, + size: { + sm: 16, + md: 20, + lg: 24, + xl: 32, + }, +}); + +// Theme context interface +interface ThemeContextType { + theme: Theme; + styles: ThemeStyles; + icons: ThemeIcons; + isDark: boolean; + currentTheme: "light" | "dark" | "system"; + systemTheme: "light" | "dark"; + setTheme: (theme: "light" | "dark" | "system") => void; + toggleTheme: () => void; +} + +// Create the theme context +const ThemeContext = createContext(null); + +// Theme provider props +interface ThemeProviderProps { + children: ReactNode; + defaultTheme?: "light" | "dark" | "system"; + forcedTheme?: "light" | "dark"; +} + +// Theme provider component +export function ThemeProvider({ + children, + defaultTheme = "system", + forcedTheme, +}: ThemeProviderProps) { + const systemColorScheme = useColorScheme(); + const [currentTheme, setCurrentTheme] = useState<"light" | "dark" | "system">( + defaultTheme, + ); + + // Determine if dark mode should be active + const isDark = useMemo(() => { + if (forcedTheme === "light") return false; + if (forcedTheme === "dark") return true; + if (currentTheme === "light") return false; + if (currentTheme === "dark") return true; + if (currentTheme === "system") return systemColorScheme === "dark"; + return systemColorScheme === "dark"; + }, [forcedTheme, currentTheme, systemColorScheme]); + + // Create theme based on dark mode + const theme = useMemo(() => { + const themeColors = createThemeColors(isDark); + return { + colors: themeColors, + spacing, + borderRadius, + typography, + shadows, + touchTargets, + animations, + }; + }, [isDark]); + + // Create utility styles + const styles = useMemo(() => { + return createThemeStyles(theme.colors); + }, [theme.colors]); + + // Create icon utilities + const icons = useMemo(() => { + return createThemeIcons(theme.colors); + }, [theme.colors]); + + // Theme controls + const setTheme = (newTheme: "light" | "dark" | "system") => { + if (!forcedTheme) { + setCurrentTheme(newTheme); + } + }; + + const toggleTheme = () => { + if (!forcedTheme) { + setCurrentTheme((prev) => { + if (prev === "light") return "dark"; + if (prev === "dark") return "system"; + return "light"; + }); + } + }; + + const value = useMemo( + () => ({ + theme, + styles, + icons, + isDark, + currentTheme: forcedTheme || currentTheme, + systemTheme: (systemColorScheme as "light" | "dark") || "light", + setTheme, + toggleTheme, + }), + [ + theme, + styles, + icons, + isDark, + forcedTheme, + currentTheme, + systemColorScheme, + setTheme, + toggleTheme, + ], + ); + + return ( + {children} + ); +} + +// Hook to use theme +export function useTheme(): ThemeContextType { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} + +// Hook to get current platform's typography +export function usePlatformTypography() { + const { theme } = useTheme(); + + return useMemo(() => { + if (Platform.OS === "ios") { + return theme.typography.ios; + } else if (Platform.OS === "android") { + return theme.typography.android; + } + return theme.typography.universal; + }, [theme.typography]); +} + +// Utility function to create theme-aware styles +export function createThemedStyles>( + styleCreator: (theme: Theme, styles: ThemeStyles, icons: ThemeIcons) => T, +) { + return function useThemedStyles() { + const { theme, styles, icons } = useTheme(); + return useMemo( + () => styleCreator(theme, styles, icons), + [theme, styles, icons], + ); + }; +} + +// Create light and dark theme instances for external use +export const lightTheme: Theme = { + colors: createThemeColors(false), + spacing, + borderRadius, + typography, + shadows, + touchTargets, + animations, +}; + +export const darkTheme: Theme = { + colors: createThemeColors(true), + spacing, + borderRadius, + typography, + shadows, + touchTargets, + animations, +}; + +// Export individual theme utilities for convenience +export { createThemeColors, createThemeIcons, createThemeStyles }; diff --git a/js/components/src/lib/theme/tokens.ts b/js/components/src/lib/theme/tokens.ts new file mode 100644 index 00000000..678938a9 --- /dev/null +++ b/js/components/src/lib/theme/tokens.ts @@ -0,0 +1,408 @@ +/** + * Design tokens for React Native components + * Inspired by shadcn/ui but adapted for React Native styling + */ + +export const colors = { + // Primary colors + primary: { + 50: "#eff6ff", + 100: "#dbeafe", + 200: "#bfdbfe", + 300: "#93c5fd", + 400: "#60a5fa", + 500: "#3b82f6", + 600: "#2563eb", + 700: "#1d4ed8", + 800: "#1e40af", + 900: "#1e3a8a", + 950: "#172554", + }, + + // Grayscale + gray: { + 50: "#f9fafb", + 100: "#f3f4f6", + 200: "#e5e7eb", + 300: "#d1d5db", + 400: "#9ca3af", + 500: "#6b7280", + 600: "#4b5563", + 700: "#374151", + 800: "#1f2937", + 900: "#111827", + 950: "#030712", + }, + + // Semantic colors + destructive: { + 50: "#fef2f2", + 100: "#fee2e2", + 200: "#fecaca", + 300: "#fca5a5", + 400: "#f87171", + 500: "#ef4444", + 600: "#dc2626", + 700: "#b91c1c", + 800: "#991b1b", + 900: "#7f1d1d", + 950: "#450a0a", + }, + + success: { + 50: "#f0fdf4", + 100: "#dcfce7", + 200: "#bbf7d0", + 300: "#86efac", + 400: "#4ade80", + 500: "#22c55e", + 600: "#16a34a", + 700: "#15803d", + 800: "#166534", + 900: "#14532d", + 950: "#052e16", + }, + + warning: { + 50: "#fffbeb", + 100: "#fef3c7", + 200: "#fde68a", + 300: "#fcd34d", + 400: "#fbbf24", + 500: "#f59e0b", + 600: "#d97706", + 700: "#b45309", + 800: "#92400e", + 900: "#78350f", + 950: "#451a03", + }, + + // iOS system colors (adaptive) + ios: { + systemBlue: "#007AFF", + systemGreen: "#34C759", + systemRed: "#FF3B30", + systemOrange: "#FF9500", + systemYellow: "#FFCC00", + systemPurple: "#AF52DE", + systemPink: "#FF2D92", + systemTeal: "#5AC8FA", + systemIndigo: "#5856D6", + systemGray: "#8E8E93", + systemGray2: "#AEAEB2", + systemGray3: "#C7C7CC", + systemGray4: "#D1D1D6", + systemGray5: "#E5E5EA", + systemGray6: "#F2F2F7", + }, + + // Android Material colors + android: { + primary: "#6200EE", + primaryVariant: "#3700B3", + secondary: "#03DAC6", + secondaryVariant: "#018786", + background: "#FFFFFF", + surface: "#FFFFFF", + error: "#B00020", + onPrimary: "#FFFFFF", + onSecondary: "#000000", + onBackground: "#000000", + onSurface: "#000000", + onError: "#FFFFFF", + }, + + // Transparent colors + transparent: "transparent", + black: "#000000", + white: "#FFFFFF", +} as const; + +export const spacing = { + 0: 0, + 1: 4, + 2: 8, + 3: 12, + 4: 16, + 5: 20, + 6: 24, + 7: 28, + 8: 32, + 9: 36, + 10: 40, + 11: 44, + 12: 48, + 14: 56, + 16: 64, + 20: 80, + 24: 96, + 28: 112, + 32: 128, + 36: 144, + 40: 160, + 44: 176, + 48: 192, + 52: 208, + 56: 224, + 60: 240, + 64: 256, + 72: 288, + 80: 320, + 96: 384, +} as const; + +export const borderRadius = { + none: 0, + sm: 4, + md: 8, + lg: 12, + xl: 16, + "2xl": 20, + "3xl": 24, + full: 999, +} as const; + +export const typography = { + // iOS system font sizes + ios: { + largeTitle: { + fontSize: 34, + lineHeight: 41, + fontWeight: "700" as const, + }, + title1: { + fontSize: 28, + lineHeight: 34, + fontWeight: "700" as const, + }, + title2: { + fontSize: 22, + lineHeight: 28, + fontWeight: "700" as const, + }, + title3: { + fontSize: 20, + lineHeight: 25, + fontWeight: "600" as const, + }, + headline: { + fontSize: 17, + lineHeight: 22, + fontWeight: "600" as const, + }, + body: { + fontSize: 17, + lineHeight: 22, + fontWeight: "400" as const, + }, + callout: { + fontSize: 16, + lineHeight: 21, + fontWeight: "400" as const, + }, + subhead: { + fontSize: 15, + lineHeight: 20, + fontWeight: "400" as const, + }, + footnote: { + fontSize: 13, + lineHeight: 18, + fontWeight: "400" as const, + }, + caption1: { + fontSize: 12, + lineHeight: 16, + fontWeight: "400" as const, + }, + caption2: { + fontSize: 11, + lineHeight: 13, + fontWeight: "400" as const, + }, + }, + + // Android Material typography + android: { + headline1: { + fontSize: 96, + lineHeight: 112, + fontWeight: "300" as const, + }, + headline2: { + fontSize: 60, + lineHeight: 72, + fontWeight: "300" as const, + }, + headline3: { + fontSize: 48, + lineHeight: 56, + fontWeight: "400" as const, + }, + headline4: { + fontSize: 34, + lineHeight: 42, + fontWeight: "400" as const, + }, + headline5: { + fontSize: 24, + lineHeight: 32, + fontWeight: "400" as const, + }, + headline6: { + fontSize: 20, + lineHeight: 28, + fontWeight: "500" as const, + }, + subtitle1: { + fontSize: 16, + lineHeight: 24, + fontWeight: "400" as const, + }, + subtitle2: { + fontSize: 14, + lineHeight: 22, + fontWeight: "500" as const, + }, + body1: { + fontSize: 16, + lineHeight: 24, + fontWeight: "400" as const, + }, + body2: { + fontSize: 14, + lineHeight: 20, + fontWeight: "400" as const, + }, + button: { + fontSize: 14, + lineHeight: 16, + fontWeight: "500" as const, + }, + caption: { + fontSize: 12, + lineHeight: 16, + fontWeight: "400" as const, + }, + overline: { + fontSize: 10, + lineHeight: 16, + fontWeight: "400" as const, + }, + }, + + // Universal typography scale + universal: { + xs: { + fontSize: 12, + lineHeight: 16, + fontWeight: "400" as const, + }, + sm: { + fontSize: 14, + lineHeight: 20, + fontWeight: "400" as const, + }, + base: { + fontSize: 16, + lineHeight: 24, + fontWeight: "400" as const, + }, + lg: { + fontSize: 18, + lineHeight: 28, + fontWeight: "400" as const, + }, + xl: { + fontSize: 20, + lineHeight: 28, + fontWeight: "500" as const, + }, + "2xl": { + fontSize: 24, + lineHeight: 32, + fontWeight: "600" as const, + }, + "3xl": { + fontSize: 30, + lineHeight: 36, + fontWeight: "700" as const, + }, + "4xl": { + fontSize: 36, + lineHeight: 40, + fontWeight: "700" as const, + }, + }, +} as const; + +export const shadows = { + none: { + shadowColor: "transparent", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + sm: { + shadowColor: colors.black, + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + md: { + shadowColor: colors.black, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 4, + }, + lg: { + shadowColor: colors.black, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 8, + }, + xl: { + shadowColor: colors.black, + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.2, + shadowRadius: 16, + elevation: 16, + }, +} as const; + +// Touch targets (iOS Human Interface Guidelines) +export const touchTargets = { + minimum: 44, // Minimum touch target size + comfortable: 48, // Comfortable touch target size + large: 56, // Large touch target size +} as const; + +// Animation durations +export const animations = { + fast: 150, + normal: 200, + slow: 300, + slower: 500, +} as const; + +// Breakpoints for responsive design +export const breakpoints = { + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + "2xl": 1536, +} as const; + +export type Colors = typeof colors; +export type Spacing = typeof spacing; +export type BorderRadius = typeof borderRadius; +export type Typography = typeof typography; +export type Shadows = typeof shadows; +export type TouchTargets = typeof touchTargets; +export type Animations = typeof animations; +export type Breakpoints = typeof breakpoints; diff --git a/js/components/src/lib/utils.ts b/js/components/src/lib/utils.ts new file mode 100644 index 00000000..6e0e4f2c --- /dev/null +++ b/js/components/src/lib/utils.ts @@ -0,0 +1,132 @@ +import { ImageStyle, StyleSheet, TextStyle, ViewStyle } from "react-native"; + +// React Native style utilities +type Style = ViewStyle | TextStyle | ImageStyle; + +/** + * Merges React Native styles similar to how cn() merges CSS classes + * Handles arrays, objects, and falsy values + */ +export function mergeStyles( + ...styles: (Style | Style[] | undefined | null | false)[] +): Style { + const validStyles = styles.filter(Boolean).flat() as Style[]; + return StyleSheet.flatten(validStyles) || {}; +} + +/** + * Creates a style merger function that includes base styles + * Useful for component variants + */ +export function createStyleMerger(baseStyle: Style) { + return (...styles: (Style | Style[] | undefined | null | false)[]) => { + return mergeStyles(baseStyle, ...styles); + }; +} + +/** + * Conditionally applies styles based on boolean conditions + */ +export function conditionalStyle( + condition: boolean, + trueStyle: Style, + falseStyle?: Style, +): Style | undefined { + return condition ? trueStyle : falseStyle; +} + +/** + * Creates responsive values based on screen dimensions + */ +export function responsiveValue( + values: { + sm?: T; + md?: T; + lg?: T; + xl?: T; + default: T; + }, + screenWidth: number, +): T { + if (screenWidth >= 1280 && values.xl !== undefined) return values.xl; + if (screenWidth >= 1024 && values.lg !== undefined) return values.lg; + if (screenWidth >= 768 && values.md !== undefined) return values.md; + if (screenWidth >= 640 && values.sm !== undefined) return values.sm; + return values.default; +} + +/** + * Creates platform-specific styles + */ +export function platformStyle(styles: { + ios?: Style; + android?: Style; + web?: Style; + default?: Style; +}): Style { + const Platform = require("react-native").Platform; + + if (Platform.OS === "ios" && styles.ios) return styles.ios; + if (Platform.OS === "android" && styles.android) return styles.android; + if (Platform.OS === "web" && styles.web) return styles.web; + return styles.default || {}; +} + +/** + * Converts hex color to rgba + */ +export function hexToRgba(hex: string, alpha: number = 1): string { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + if (!result) return hex; + + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** + * Creates a debounced function for performance + */ +export function debounce any>( + func: T, + delay: number, +): (...args: Parameters) => void { + let timeoutId: NodeJS.Timeout; + + return (...args: Parameters) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func(...args), delay); + }; +} + +/** + * Creates a throttled function for performance + */ +export function throttle any>( + func: T, + delay: number, +): (...args: Parameters) => void { + let lastCall = 0; + + return (...args: Parameters) => { + const now = Date.now(); + if (now - lastCall >= delay) { + lastCall = now; + func(...args); + } + }; +} + +/** + * Type-safe component prop forwarding + */ +export function forwardProps>( + props: T, + omit: (keyof T)[], +): Omit { + const result = { ...props }; + omit.forEach((key) => delete result[key]); + return result; +}