@retray-dev/ui-kit 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/COMPONENTS.md +654 -0
  2. package/LICENSE +21 -0
  3. package/README.md +151 -0
  4. package/dist/index.d.mts +309 -3
  5. package/dist/index.d.ts +309 -3
  6. package/dist/index.js +1477 -57
  7. package/dist/index.mjs +1424 -57
  8. package/package.json +27 -5
  9. package/src/components/Accordion/Accordion.tsx +161 -0
  10. package/src/components/Accordion/index.ts +2 -0
  11. package/src/components/Alert/Alert.tsx +57 -0
  12. package/src/components/Alert/index.ts +2 -0
  13. package/src/components/Avatar/Avatar.tsx +67 -0
  14. package/src/components/Avatar/index.ts +2 -0
  15. package/src/components/Badge/Badge.tsx +48 -0
  16. package/src/components/Badge/index.ts +2 -0
  17. package/src/components/Button/Button.tsx +78 -45
  18. package/src/components/Card/Card.tsx +109 -0
  19. package/src/components/Card/index.ts +9 -0
  20. package/src/components/Checkbox/Checkbox.tsx +70 -0
  21. package/src/components/Checkbox/index.ts +2 -0
  22. package/src/components/EmptyState/EmptyState.tsx +69 -0
  23. package/src/components/EmptyState/index.ts +2 -0
  24. package/src/components/Input/Input.tsx +26 -41
  25. package/src/components/Progress/Progress.tsx +53 -0
  26. package/src/components/Progress/index.ts +2 -0
  27. package/src/components/RadioGroup/RadioGroup.tsx +105 -0
  28. package/src/components/RadioGroup/index.ts +2 -0
  29. package/src/components/Select/Select.tsx +185 -0
  30. package/src/components/Select/index.ts +2 -0
  31. package/src/components/Separator/Separator.tsx +33 -0
  32. package/src/components/Separator/index.ts +2 -0
  33. package/src/components/Sheet/Sheet.tsx +108 -0
  34. package/src/components/Sheet/index.ts +2 -0
  35. package/src/components/Skeleton/Skeleton.tsx +40 -0
  36. package/src/components/Skeleton/index.ts +2 -0
  37. package/src/components/Slider/Slider.tsx +142 -0
  38. package/src/components/Slider/index.ts +2 -0
  39. package/src/components/Spinner/Spinner.tsx +27 -0
  40. package/src/components/Spinner/index.ts +2 -0
  41. package/src/components/Switch/Switch.tsx +82 -0
  42. package/src/components/Switch/index.ts +2 -0
  43. package/src/components/Tabs/Tabs.tsx +145 -0
  44. package/src/components/Tabs/index.ts +2 -0
  45. package/src/components/Text/Text.tsx +10 -4
  46. package/src/components/Textarea/Textarea.tsx +70 -0
  47. package/src/components/Textarea/index.ts +2 -0
  48. package/src/components/Toast/Toast.tsx +164 -0
  49. package/src/components/Toast/index.ts +2 -0
  50. package/src/components/Toggle/Toggle.tsx +80 -0
  51. package/src/components/Toggle/index.ts +2 -0
  52. package/src/index.ts +26 -0
  53. package/src/theme/ThemeProvider.tsx +47 -0
  54. package/src/theme/colors.ts +41 -0
  55. package/src/theme/index.ts +4 -0
  56. package/src/theme/types.ts +31 -0
@@ -0,0 +1,108 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import { View, Text, StyleSheet, ViewStyle } from 'react-native'
3
+ import {
4
+ BottomSheetModal,
5
+ BottomSheetView,
6
+ BottomSheetBackdrop,
7
+ BottomSheetModalProvider,
8
+ type BottomSheetBackdropProps,
9
+ } from '@gorhom/bottom-sheet'
10
+ import * as Haptics from 'expo-haptics'
11
+ import { useTheme } from '../../theme'
12
+
13
+ export { BottomSheetModalProvider }
14
+
15
+ export interface SheetProps {
16
+ open: boolean
17
+ onClose: () => void
18
+ title?: string
19
+ description?: string
20
+ children?: React.ReactNode
21
+ snapPoints?: (string | number)[]
22
+ style?: ViewStyle
23
+ }
24
+
25
+ export function Sheet({
26
+ open,
27
+ onClose,
28
+ title,
29
+ description,
30
+ children,
31
+ snapPoints = ['50%'],
32
+ style,
33
+ }: SheetProps) {
34
+ const { colors } = useTheme()
35
+ const ref = useRef<BottomSheetModal>(null)
36
+
37
+ useEffect(() => {
38
+ if (open) {
39
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
40
+ ref.current?.present()
41
+ } else {
42
+ ref.current?.dismiss()
43
+ }
44
+ }, [open])
45
+
46
+ const renderBackdrop = (props: BottomSheetBackdropProps) => (
47
+ <BottomSheetBackdrop
48
+ {...props}
49
+ disappearsOnIndex={-1}
50
+ appearsOnIndex={0}
51
+ pressBehavior="close"
52
+ />
53
+ )
54
+
55
+ return (
56
+ <BottomSheetModal
57
+ ref={ref}
58
+ snapPoints={snapPoints}
59
+ onDismiss={onClose}
60
+ backdropComponent={renderBackdrop}
61
+ backgroundStyle={[styles.background, { backgroundColor: colors.card }]}
62
+ handleIndicatorStyle={[styles.handle, { backgroundColor: colors.border }]}
63
+ enablePanDownToClose
64
+ >
65
+ <BottomSheetView style={[styles.content, style]}>
66
+ {(title || description) ? (
67
+ <View style={styles.header}>
68
+ {title ? (
69
+ <Text style={[styles.title, { color: colors.cardForeground }]}>{title}</Text>
70
+ ) : null}
71
+ {description ? (
72
+ <Text style={[styles.description, { color: colors.mutedForeground }]}>{description}</Text>
73
+ ) : null}
74
+ </View>
75
+ ) : null}
76
+ {children}
77
+ </BottomSheetView>
78
+ </BottomSheetModal>
79
+ )
80
+ }
81
+
82
+ const styles = StyleSheet.create({
83
+ background: {
84
+ borderTopLeftRadius: 16,
85
+ borderTopRightRadius: 16,
86
+ },
87
+ handle: {
88
+ width: 36,
89
+ height: 4,
90
+ borderRadius: 2,
91
+ },
92
+ content: {
93
+ paddingHorizontal: 24,
94
+ paddingBottom: 32,
95
+ },
96
+ header: {
97
+ gap: 8,
98
+ marginBottom: 16,
99
+ },
100
+ title: {
101
+ fontSize: 18,
102
+ fontWeight: '600',
103
+ },
104
+ description: {
105
+ fontSize: 14,
106
+ lineHeight: 20,
107
+ },
108
+ })
@@ -0,0 +1,2 @@
1
+ export { Sheet, BottomSheetModalProvider } from './Sheet'
2
+ export type { SheetProps } from './Sheet'
@@ -0,0 +1,40 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import { Animated, StyleSheet, ViewStyle } from 'react-native'
3
+ import { useTheme } from '../../theme'
4
+
5
+ export interface SkeletonProps {
6
+ width?: number | string
7
+ height?: number
8
+ borderRadius?: number
9
+ style?: ViewStyle
10
+ }
11
+
12
+ export function Skeleton({ width = '100%', height = 16, borderRadius = 6, style }: SkeletonProps) {
13
+ const { colors } = useTheme()
14
+ const opacity = useRef(new Animated.Value(1)).current
15
+
16
+ useEffect(() => {
17
+ const animation = Animated.loop(
18
+ Animated.sequence([
19
+ Animated.timing(opacity, { toValue: 0.4, duration: 800, useNativeDriver: true }),
20
+ Animated.timing(opacity, { toValue: 1, duration: 800, useNativeDriver: true }),
21
+ ])
22
+ )
23
+ animation.start()
24
+ return () => animation.stop()
25
+ }, [opacity])
26
+
27
+ return (
28
+ <Animated.View
29
+ style={[
30
+ styles.base,
31
+ { width: width as any, height, borderRadius, backgroundColor: colors.muted, opacity },
32
+ style,
33
+ ]}
34
+ />
35
+ )
36
+ }
37
+
38
+ const styles = StyleSheet.create({
39
+ base: {},
40
+ })
@@ -0,0 +1,2 @@
1
+ export { Skeleton } from './Skeleton'
2
+ export type { SkeletonProps } from './Skeleton'
@@ -0,0 +1,142 @@
1
+ import React, { useRef, useState } from 'react'
2
+ import { View, PanResponder, StyleSheet, LayoutChangeEvent, ViewStyle } from 'react-native'
3
+ import * as Haptics from 'expo-haptics'
4
+ import { useTheme } from '../../theme'
5
+
6
+ export interface SliderProps {
7
+ value?: number
8
+ minimumValue?: number
9
+ maximumValue?: number
10
+ step?: number
11
+ onValueChange?: (value: number) => void
12
+ onSlidingComplete?: (value: number) => void
13
+ disabled?: boolean
14
+ style?: ViewStyle
15
+ }
16
+
17
+ export function Slider({
18
+ value = 0,
19
+ minimumValue = 0,
20
+ maximumValue = 1,
21
+ step = 0,
22
+ onValueChange,
23
+ onSlidingComplete,
24
+ disabled,
25
+ style,
26
+ }: SliderProps) {
27
+ const { colors } = useTheme()
28
+ const trackWidth = useRef(0)
29
+ const lastSteppedValue = useRef(value)
30
+ const [internalValue, setInternalValue] = useState(value)
31
+ const currentValue = value ?? internalValue
32
+
33
+ const clamp = (v: number) => Math.min(Math.max(v, minimumValue), maximumValue)
34
+
35
+ const snapToStep = (v: number) => {
36
+ if (!step) return v
37
+ return Math.round((v - minimumValue) / step) * step + minimumValue
38
+ }
39
+
40
+ const xToValue = (x: number): number => {
41
+ const ratio = Math.min(Math.max(x / trackWidth.current, 0), 1)
42
+ const raw = ratio * (maximumValue - minimumValue) + minimumValue
43
+ return clamp(snapToStep(raw))
44
+ }
45
+
46
+ const panResponder = useRef(
47
+ PanResponder.create({
48
+ onStartShouldSetPanResponder: () => !disabled,
49
+ onMoveShouldSetPanResponder: () => !disabled,
50
+ onPanResponderGrant: (e) => {
51
+ const x = e.nativeEvent.locationX
52
+ const newValue = xToValue(x)
53
+ setInternalValue(newValue)
54
+ onValueChange?.(newValue)
55
+ },
56
+ onPanResponderMove: (e) => {
57
+ const x = e.nativeEvent.locationX
58
+ const newValue = xToValue(x)
59
+ if (newValue !== lastSteppedValue.current) {
60
+ lastSteppedValue.current = newValue
61
+ Haptics.selectionAsync()
62
+ }
63
+ setInternalValue(newValue)
64
+ onValueChange?.(newValue)
65
+ },
66
+ onPanResponderRelease: (e) => {
67
+ const x = e.nativeEvent.locationX
68
+ const newValue = xToValue(x)
69
+ setInternalValue(newValue)
70
+ onSlidingComplete?.(newValue)
71
+ },
72
+ })
73
+ ).current
74
+
75
+ const onLayout = (e: LayoutChangeEvent) => {
76
+ trackWidth.current = e.nativeEvent.layout.width
77
+ }
78
+
79
+ const percent = ((currentValue - minimumValue) / (maximumValue - minimumValue)) * 100
80
+
81
+ return (
82
+ <View
83
+ style={[styles.container, disabled && styles.disabled, style]}
84
+ {...panResponder.panHandlers}
85
+ onLayout={onLayout}
86
+ >
87
+ <View style={[styles.track, { backgroundColor: colors.muted }]}>
88
+ <View
89
+ style={[
90
+ styles.range,
91
+ { width: `${percent}%` as any, backgroundColor: colors.primary },
92
+ ]}
93
+ />
94
+ </View>
95
+ <View
96
+ style={[
97
+ styles.thumb,
98
+ {
99
+ left: `${percent}%` as any,
100
+ backgroundColor: colors.primary,
101
+ borderColor: colors.background,
102
+ transform: [{ translateX: -14 }],
103
+ },
104
+ ]}
105
+ pointerEvents="none"
106
+ />
107
+ </View>
108
+ )
109
+ }
110
+
111
+ const styles = StyleSheet.create({
112
+ container: {
113
+ height: 32,
114
+ justifyContent: 'center',
115
+ position: 'relative',
116
+ },
117
+ disabled: {
118
+ opacity: 0.45,
119
+ },
120
+ track: {
121
+ height: 6,
122
+ borderRadius: 3,
123
+ overflow: 'hidden',
124
+ width: '100%',
125
+ },
126
+ range: {
127
+ height: '100%',
128
+ borderRadius: 3,
129
+ },
130
+ thumb: {
131
+ position: 'absolute',
132
+ width: 28,
133
+ height: 28,
134
+ borderRadius: 14,
135
+ borderWidth: 2,
136
+ shadowColor: '#000',
137
+ shadowOffset: { width: 0, height: 1 },
138
+ shadowOpacity: 0.2,
139
+ shadowRadius: 2,
140
+ elevation: 2,
141
+ },
142
+ })
@@ -0,0 +1,2 @@
1
+ export { Slider } from './Slider'
2
+ export type { SliderProps } from './Slider'
@@ -0,0 +1,27 @@
1
+ import React from 'react'
2
+ import { ActivityIndicator, ActivityIndicatorProps } from 'react-native'
3
+ import { useTheme } from '../../theme'
4
+
5
+ export type SpinnerSize = 'sm' | 'md' | 'lg'
6
+
7
+ export interface SpinnerProps extends Omit<ActivityIndicatorProps, 'size'> {
8
+ size?: SpinnerSize
9
+ color?: string
10
+ }
11
+
12
+ const sizeMap: Record<SpinnerSize, 'small' | 'large'> = {
13
+ sm: 'small',
14
+ md: 'small',
15
+ lg: 'large',
16
+ }
17
+
18
+ export function Spinner({ size = 'md', color, ...props }: SpinnerProps) {
19
+ const { colors } = useTheme()
20
+ return (
21
+ <ActivityIndicator
22
+ size={sizeMap[size]}
23
+ color={color ?? colors.primary}
24
+ {...props}
25
+ />
26
+ )
27
+ }
@@ -0,0 +1,2 @@
1
+ export { Spinner } from './Spinner'
2
+ export type { SpinnerProps, SpinnerSize } from './Spinner'
@@ -0,0 +1,82 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+ import { TouchableOpacity, Animated, StyleSheet, ViewStyle } from 'react-native'
3
+ import * as Haptics from 'expo-haptics'
4
+ import { useTheme } from '../../theme'
5
+
6
+ const TRACK_WIDTH = 56
7
+ const TRACK_HEIGHT = 32
8
+ const THUMB_SIZE = 24
9
+ const THUMB_OFFSET = 4
10
+ const THUMB_TRAVEL = TRACK_WIDTH - THUMB_SIZE - THUMB_OFFSET * 2
11
+
12
+ export interface SwitchProps {
13
+ checked?: boolean
14
+ onCheckedChange?: (checked: boolean) => void
15
+ disabled?: boolean
16
+ style?: ViewStyle
17
+ }
18
+
19
+ export function Switch({ checked = false, onCheckedChange, disabled, style }: SwitchProps) {
20
+ const { colors } = useTheme()
21
+ const translateX = useRef(new Animated.Value(checked ? THUMB_TRAVEL : 0)).current
22
+ const trackOpacity = useRef(new Animated.Value(checked ? 1 : 0)).current
23
+
24
+ useEffect(() => {
25
+ Animated.parallel([
26
+ Animated.spring(translateX, {
27
+ toValue: checked ? THUMB_TRAVEL : 0,
28
+ useNativeDriver: true,
29
+ bounciness: 4,
30
+ }),
31
+ Animated.timing(trackOpacity, {
32
+ toValue: checked ? 1 : 0,
33
+ duration: 150,
34
+ useNativeDriver: false,
35
+ }),
36
+ ]).start()
37
+ }, [checked, translateX, trackOpacity])
38
+
39
+ const trackColor = trackOpacity.interpolate({
40
+ inputRange: [0, 1],
41
+ outputRange: [colors.muted, colors.primary],
42
+ })
43
+
44
+ return (
45
+ <TouchableOpacity
46
+ onPress={() => { Haptics.selectionAsync(); onCheckedChange?.(!checked) }}
47
+ disabled={disabled}
48
+ activeOpacity={0.8}
49
+ style={[styles.wrapper, { opacity: disabled ? 0.45 : 1 }, style]}
50
+ >
51
+ <Animated.View style={[styles.track, { backgroundColor: trackColor }]}>
52
+ <Animated.View
53
+ style={[
54
+ styles.thumb,
55
+ { backgroundColor: colors.primaryForeground, transform: [{ translateX }] },
56
+ ]}
57
+ />
58
+ </Animated.View>
59
+ </TouchableOpacity>
60
+ )
61
+ }
62
+
63
+ const styles = StyleSheet.create({
64
+ wrapper: {},
65
+ track: {
66
+ width: TRACK_WIDTH,
67
+ height: TRACK_HEIGHT,
68
+ borderRadius: TRACK_HEIGHT / 2,
69
+ justifyContent: 'center',
70
+ paddingHorizontal: THUMB_OFFSET,
71
+ },
72
+ thumb: {
73
+ width: THUMB_SIZE,
74
+ height: THUMB_SIZE,
75
+ borderRadius: THUMB_SIZE / 2,
76
+ shadowColor: '#000',
77
+ shadowOffset: { width: 0, height: 1 },
78
+ shadowOpacity: 0.15,
79
+ shadowRadius: 2,
80
+ elevation: 2,
81
+ },
82
+ })
@@ -0,0 +1,2 @@
1
+ export { Switch } from './Switch'
2
+ export type { SwitchProps } from './Switch'
@@ -0,0 +1,145 @@
1
+ import React, { useState, useRef, useEffect } from 'react'
2
+ import { View, TouchableOpacity, Text, Animated, StyleSheet, ViewStyle } from 'react-native'
3
+ import { useTheme } from '../../theme'
4
+
5
+ export interface TabItem {
6
+ label: string
7
+ value: string
8
+ }
9
+
10
+ export interface TabsProps {
11
+ tabs: TabItem[]
12
+ value?: string
13
+ onValueChange?: (value: string) => void
14
+ children?: React.ReactNode
15
+ style?: ViewStyle
16
+ }
17
+
18
+ export interface TabsContentProps {
19
+ value: string
20
+ activeValue: string
21
+ children: React.ReactNode
22
+ style?: ViewStyle
23
+ }
24
+
25
+ export function Tabs({ tabs, value, onValueChange, children, style }: TabsProps) {
26
+ const [internal, setInternal] = useState(tabs[0]?.value ?? '')
27
+ const { colors } = useTheme()
28
+ const active = value ?? internal
29
+
30
+ const tabLayouts = useRef<Record<string, { x: number; width: number }>>({})
31
+ const pillX = useRef(new Animated.Value(0)).current
32
+ const pillWidth = useRef(new Animated.Value(0)).current
33
+ const initialised = useRef(false)
34
+
35
+ const animatePill = (tabValue: string, animate: boolean) => {
36
+ const layout = tabLayouts.current[tabValue]
37
+ if (!layout) return
38
+ if (animate) {
39
+ Animated.parallel([
40
+ Animated.spring(pillX, { toValue: layout.x, useNativeDriver: false, speed: 20, bounciness: 0 }),
41
+ Animated.spring(pillWidth, { toValue: layout.width, useNativeDriver: false, speed: 20, bounciness: 0 }),
42
+ ]).start()
43
+ } else {
44
+ pillX.setValue(layout.x)
45
+ pillWidth.setValue(layout.width)
46
+ }
47
+ }
48
+
49
+ useEffect(() => {
50
+ if (initialised.current) {
51
+ animatePill(active, true)
52
+ }
53
+ }, [active])
54
+
55
+ const handlePress = (v: string) => {
56
+ if (!value) setInternal(v)
57
+ onValueChange?.(v)
58
+ }
59
+
60
+ return (
61
+ <View style={style}>
62
+ <View style={[styles.list, { backgroundColor: colors.muted }]}>
63
+ <Animated.View
64
+ style={[
65
+ styles.pill,
66
+ {
67
+ backgroundColor: colors.background,
68
+ position: 'absolute',
69
+ top: 4,
70
+ bottom: 4,
71
+ left: pillX,
72
+ width: pillWidth,
73
+ borderRadius: 6,
74
+ shadowColor: '#000',
75
+ shadowOffset: { width: 0, height: 1 },
76
+ shadowOpacity: 0.1,
77
+ shadowRadius: 2,
78
+ elevation: 2,
79
+ },
80
+ ]}
81
+ />
82
+ {tabs.map((tab) => {
83
+ const isActive = tab.value === active
84
+ return (
85
+ <TouchableOpacity
86
+ key={tab.value}
87
+ style={styles.trigger}
88
+ onPress={() => handlePress(tab.value)}
89
+ activeOpacity={0.7}
90
+ onLayout={(e) => {
91
+ const { x, width } = e.nativeEvent.layout
92
+ tabLayouts.current[tab.value] = { x, width }
93
+ if (tab.value === active) {
94
+ animatePill(tab.value, false)
95
+ initialised.current = true
96
+ }
97
+ }}
98
+ >
99
+ <Text
100
+ style={[
101
+ styles.triggerLabel,
102
+ { color: isActive ? colors.foreground : colors.mutedForeground },
103
+ isActive && styles.activeTriggerLabel,
104
+ ]}
105
+ >
106
+ {tab.label}
107
+ </Text>
108
+ </TouchableOpacity>
109
+ )
110
+ })}
111
+ </View>
112
+ {children}
113
+ </View>
114
+ )
115
+ }
116
+
117
+ export function TabsContent({ value, activeValue, children, style }: TabsContentProps) {
118
+ if (value !== activeValue) return null
119
+ return <View style={style}>{children}</View>
120
+ }
121
+
122
+ const styles = StyleSheet.create({
123
+ list: {
124
+ flexDirection: 'row',
125
+ borderRadius: 8,
126
+ padding: 4,
127
+ gap: 4,
128
+ },
129
+ pill: {},
130
+ trigger: {
131
+ flex: 1,
132
+ paddingVertical: 8,
133
+ paddingHorizontal: 12,
134
+ borderRadius: 6,
135
+ alignItems: 'center',
136
+ zIndex: 1,
137
+ },
138
+ triggerLabel: {
139
+ fontSize: 14,
140
+ fontWeight: '400',
141
+ },
142
+ activeTriggerLabel: {
143
+ fontWeight: '500',
144
+ },
145
+ })
@@ -0,0 +1,2 @@
1
+ export { Tabs, TabsContent } from './Tabs'
2
+ export type { TabsProps, TabsContentProps, TabItem } from './Tabs'
@@ -1,5 +1,6 @@
1
1
  import React from 'react'
2
- import { Text as RNText, StyleSheet, TextProps as RNTextProps, TextStyle } from 'react-native'
2
+ import { Text as RNText, TextProps as RNTextProps, TextStyle } from 'react-native'
3
+ import { useTheme } from '../../theme'
3
4
 
4
5
  export type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label'
5
6
 
@@ -9,18 +10,23 @@ export interface TextProps extends RNTextProps {
9
10
  }
10
11
 
11
12
  const variantStyles: Record<TextVariant, TextStyle> = {
12
- h1: { fontSize: 32, fontWeight: '700', lineHeight: 40 },
13
+ h1: { fontSize: 32, fontWeight: '700', lineHeight: 44 },
13
14
  h2: { fontSize: 24, fontWeight: '700', lineHeight: 32 },
14
15
  h3: { fontSize: 20, fontWeight: '600', lineHeight: 28 },
15
16
  body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
16
- caption: { fontSize: 12, fontWeight: '400', lineHeight: 18, color: '#6B7280' },
17
+ caption: { fontSize: 12, fontWeight: '400', lineHeight: 18 },
17
18
  label: { fontSize: 14, fontWeight: '500', lineHeight: 20 },
18
19
  }
19
20
 
20
21
  export function Text({ variant = 'body', color, style, children, ...props }: TextProps) {
22
+ const { colors } = useTheme()
23
+
24
+ const defaultColor = variant === 'caption' ? colors.mutedForeground : colors.foreground
25
+
21
26
  return (
22
27
  <RNText
23
- style={[variantStyles[variant], color ? { color } : undefined, style]}
28
+ style={[variantStyles[variant], { color: color ?? defaultColor }, style]}
29
+ allowFontScaling={true}
24
30
  {...props}
25
31
  >
26
32
  {children}
@@ -0,0 +1,70 @@
1
+ import React, { useState } from 'react'
2
+ import { TextInput, View, Text, StyleSheet, TextInputProps } from 'react-native'
3
+ import { useTheme } from '../../theme'
4
+
5
+ export interface TextareaProps extends TextInputProps {
6
+ label?: string
7
+ error?: string
8
+ hint?: string
9
+ rows?: number
10
+ }
11
+
12
+ export function Textarea({ label, error, hint, rows = 4, style, onFocus, onBlur, ...props }: TextareaProps) {
13
+ const { colors } = useTheme()
14
+ const [focused, setFocused] = useState(false)
15
+
16
+ return (
17
+ <View style={styles.container}>
18
+ {label ? (
19
+ <Text style={[styles.label, { color: colors.foreground }]}>{label}</Text>
20
+ ) : null}
21
+ <TextInput
22
+ multiline
23
+ numberOfLines={rows}
24
+ textAlignVertical="top"
25
+ style={[
26
+ styles.input,
27
+ {
28
+ borderColor: error ? colors.destructive : focused ? colors.ring : colors.border,
29
+ color: colors.foreground,
30
+ backgroundColor: colors.background,
31
+ minHeight: rows * 28,
32
+ },
33
+ style,
34
+ ]}
35
+ onFocus={(e) => { setFocused(true); onFocus?.(e) }}
36
+ onBlur={(e) => { setFocused(false); onBlur?.(e) }}
37
+ placeholderTextColor={colors.mutedForeground}
38
+ allowFontScaling={true}
39
+ {...props}
40
+ />
41
+ {error ? (
42
+ <Text style={[styles.helperText, { color: colors.destructive }]}>{error}</Text>
43
+ ) : null}
44
+ {!error && hint ? (
45
+ <Text style={[styles.helperText, { color: colors.mutedForeground }]}>{hint}</Text>
46
+ ) : null}
47
+ </View>
48
+ )
49
+ }
50
+
51
+ const styles = StyleSheet.create({
52
+ container: {
53
+ gap: 4,
54
+ },
55
+ label: {
56
+ fontSize: 14,
57
+ fontWeight: '500',
58
+ marginBottom: 4,
59
+ },
60
+ input: {
61
+ borderWidth: 1.5,
62
+ borderRadius: 8,
63
+ paddingHorizontal: 16,
64
+ paddingVertical: 14,
65
+ fontSize: 16,
66
+ },
67
+ helperText: {
68
+ fontSize: 12,
69
+ },
70
+ })
@@ -0,0 +1,2 @@
1
+ export { Textarea } from './Textarea'
2
+ export type { TextareaProps } from './Textarea'