@astacinco/rn-primitives 0.1.0 → 0.2.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 (48) hide show
  1. package/README.md +195 -0
  2. package/__tests__/Tabs.test.tsx +194 -0
  3. package/__tests__/Tag.test.tsx +123 -0
  4. package/__tests__/Timer.test.tsx +208 -0
  5. package/package.json +10 -6
  6. package/src/AppFooter/AppFooter.tsx +113 -0
  7. package/src/AppFooter/index.ts +2 -0
  8. package/src/AppFooter/types.ts +39 -0
  9. package/src/AppHeader/AppHeader.tsx +165 -0
  10. package/src/AppHeader/index.ts +2 -0
  11. package/src/AppHeader/types.ts +82 -0
  12. package/src/Avatar/Avatar.tsx +111 -0
  13. package/src/Avatar/index.ts +2 -0
  14. package/src/Avatar/types.ts +63 -0
  15. package/src/Badge/Badge.tsx +150 -0
  16. package/src/Badge/index.ts +2 -0
  17. package/src/Badge/types.ts +93 -0
  18. package/src/Button/Button.tsx +34 -20
  19. package/src/Button/types.ts +1 -1
  20. package/src/FloatingTierBadge/FloatingTierBadge.tsx +100 -0
  21. package/src/FloatingTierBadge/index.ts +2 -0
  22. package/src/FloatingTierBadge/types.ts +29 -0
  23. package/src/Input/Input.tsx +8 -23
  24. package/src/MarkdownViewer/MarkdownViewer.tsx +185 -0
  25. package/src/MarkdownViewer/index.ts +2 -0
  26. package/src/MarkdownViewer/types.ts +18 -0
  27. package/src/Modal/Modal.tsx +136 -0
  28. package/src/Modal/index.ts +2 -0
  29. package/src/Modal/types.ts +68 -0
  30. package/src/ProBadge/ProBadge.tsx +59 -0
  31. package/src/ProBadge/index.ts +2 -0
  32. package/src/ProBadge/types.ts +13 -0
  33. package/src/ProLockOverlay/ProLockOverlay.tsx +106 -0
  34. package/src/ProLockOverlay/index.ts +2 -0
  35. package/src/ProLockOverlay/types.ts +22 -0
  36. package/src/Switch/Switch.tsx +120 -0
  37. package/src/Switch/index.ts +2 -0
  38. package/src/Switch/types.ts +58 -0
  39. package/src/Tabs/Tabs.tsx +137 -0
  40. package/src/Tabs/index.ts +2 -0
  41. package/src/Tabs/types.ts +66 -0
  42. package/src/Tag/Tag.tsx +100 -0
  43. package/src/Tag/index.ts +2 -0
  44. package/src/Tag/types.ts +42 -0
  45. package/src/Timer/Timer.tsx +170 -0
  46. package/src/Timer/index.ts +2 -0
  47. package/src/Timer/types.ts +69 -0
  48. package/src/index.ts +52 -0
@@ -0,0 +1,208 @@
1
+ import React from 'react';
2
+ import { fireEvent, act } from '@testing-library/react-native';
3
+ import { renderWithTheme, createThemeSnapshot } from '@astacinco/rn-testing';
4
+ import { Timer } from '../src/Timer';
5
+
6
+ describe('Timer', () => {
7
+ beforeEach(() => {
8
+ jest.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ jest.useRealTimers();
13
+ });
14
+
15
+ // Snapshot tests for both themes
16
+ createThemeSnapshot(<Timer durationMinutes={5} testID="timer" />);
17
+
18
+ describe('rendering', () => {
19
+ it('renders_initial_time', () => {
20
+ const { getByText } = renderWithTheme(
21
+ <Timer durationMinutes={5} testID="timer" />
22
+ );
23
+
24
+ expect(getByText('05:00')).toBeTruthy();
25
+ });
26
+
27
+ it('renders_controls_by_default', () => {
28
+ const { getByText } = renderWithTheme(
29
+ <Timer durationMinutes={5} showControls testID="timer" />
30
+ );
31
+
32
+ expect(getByText('Start')).toBeTruthy();
33
+ });
34
+
35
+ it('hides_controls_when_showControls_false', () => {
36
+ const { queryByText } = renderWithTheme(
37
+ <Timer durationMinutes={5} showControls={false} testID="timer" />
38
+ );
39
+
40
+ expect(queryByText('Start')).toBeNull();
41
+ });
42
+ });
43
+
44
+ describe('timer_display', () => {
45
+ it('shows_correct_format_for_minutes', () => {
46
+ const { getByText } = renderWithTheme(
47
+ <Timer durationMinutes={90} testID="timer" />
48
+ );
49
+
50
+ expect(getByText('90:00')).toBeTruthy();
51
+ });
52
+
53
+ it('shows_correct_format_for_1_minute', () => {
54
+ const { getByText } = renderWithTheme(
55
+ <Timer durationMinutes={1} testID="timer" />
56
+ );
57
+
58
+ expect(getByText('01:00')).toBeTruthy();
59
+ });
60
+ });
61
+
62
+ describe('controls', () => {
63
+ it('shows_Start_button_initially', () => {
64
+ const { getByText } = renderWithTheme(
65
+ <Timer durationMinutes={5} showControls testID="timer" />
66
+ );
67
+
68
+ expect(getByText('Start')).toBeTruthy();
69
+ });
70
+
71
+ it('shows_Pause_button_when_running', () => {
72
+ const { getByText } = renderWithTheme(
73
+ <Timer durationMinutes={5} showControls testID="timer" />
74
+ );
75
+
76
+ fireEvent.press(getByText('Start'));
77
+ expect(getByText('Pause')).toBeTruthy();
78
+ });
79
+
80
+ it('shows_Resume_button_when_paused', () => {
81
+ const { getByText } = renderWithTheme(
82
+ <Timer durationMinutes={5} showControls testID="timer" />
83
+ );
84
+
85
+ fireEvent.press(getByText('Start'));
86
+ fireEvent.press(getByText('Pause'));
87
+ expect(getByText('Resume')).toBeTruthy();
88
+ });
89
+
90
+ it('shows_Reset_button_when_not_idle', () => {
91
+ const { getByText } = renderWithTheme(
92
+ <Timer durationMinutes={5} showControls testID="timer" />
93
+ );
94
+
95
+ fireEvent.press(getByText('Start'));
96
+ expect(getByText('Reset')).toBeTruthy();
97
+ });
98
+ });
99
+
100
+ describe('callbacks', () => {
101
+ it('calls_onStart_when_started', () => {
102
+ const mockOnStart = jest.fn();
103
+ const { getByText } = renderWithTheme(
104
+ <Timer durationMinutes={5} onStart={mockOnStart} showControls testID="timer" />
105
+ );
106
+
107
+ fireEvent.press(getByText('Start'));
108
+ expect(mockOnStart).toHaveBeenCalledTimes(1);
109
+ });
110
+
111
+ it('calls_onPause_when_paused', () => {
112
+ const mockOnPause = jest.fn();
113
+ const { getByText } = renderWithTheme(
114
+ <Timer durationMinutes={5} onPause={mockOnPause} showControls testID="timer" />
115
+ );
116
+
117
+ fireEvent.press(getByText('Start'));
118
+ fireEvent.press(getByText('Pause'));
119
+ expect(mockOnPause).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it('calls_onReset_when_reset', () => {
123
+ const mockOnReset = jest.fn();
124
+ const { getByText } = renderWithTheme(
125
+ <Timer durationMinutes={5} onReset={mockOnReset} showControls testID="timer" />
126
+ );
127
+
128
+ fireEvent.press(getByText('Start'));
129
+ fireEvent.press(getByText('Reset'));
130
+ expect(mockOnReset).toHaveBeenCalledTimes(1);
131
+ });
132
+ });
133
+
134
+ describe('countdown', () => {
135
+ it('counts_down_when_running', () => {
136
+ const { getByText } = renderWithTheme(
137
+ <Timer durationMinutes={1} showControls testID="timer" />
138
+ );
139
+
140
+ fireEvent.press(getByText('Start'));
141
+
142
+ act(() => {
143
+ jest.advanceTimersByTime(1000);
144
+ });
145
+
146
+ expect(getByText('00:59')).toBeTruthy();
147
+ });
148
+
149
+ it('resets_to_initial_time_when_reset', () => {
150
+ const { getByText } = renderWithTheme(
151
+ <Timer durationMinutes={1} showControls testID="timer" />
152
+ );
153
+
154
+ fireEvent.press(getByText('Start'));
155
+
156
+ act(() => {
157
+ jest.advanceTimersByTime(5000);
158
+ });
159
+
160
+ fireEvent.press(getByText('Reset'));
161
+ expect(getByText('01:00')).toBeTruthy();
162
+ });
163
+ });
164
+
165
+ describe('autoStart', () => {
166
+ it('starts_automatically_when_autoStart_true', () => {
167
+ const mockOnStart = jest.fn();
168
+ const { getByText } = renderWithTheme(
169
+ <Timer
170
+ durationMinutes={1}
171
+ autoStart
172
+ onStart={mockOnStart}
173
+ showControls
174
+ testID="timer"
175
+ />
176
+ );
177
+
178
+ expect(mockOnStart).toHaveBeenCalledTimes(1);
179
+ expect(getByText('Pause')).toBeTruthy();
180
+ });
181
+ });
182
+
183
+ describe('progress', () => {
184
+ it('shows_progress_when_showProgress_true', () => {
185
+ const { getByTestId } = renderWithTheme(
186
+ <Timer durationMinutes={5} showProgress testID="timer" />
187
+ );
188
+
189
+ expect(getByTestId('timer')).toBeTruthy();
190
+ });
191
+ });
192
+
193
+ describe('theming', () => {
194
+ it('uses_different_colors_inDarkMode', () => {
195
+ const lightResult = renderWithTheme(
196
+ <Timer durationMinutes={5} testID="timer" />,
197
+ 'light'
198
+ );
199
+ const darkResult = renderWithTheme(
200
+ <Timer durationMinutes={5} testID="timer" />,
201
+ 'dark'
202
+ );
203
+
204
+ expect(lightResult.getByTestId('timer')).toBeTruthy();
205
+ expect(darkResult.getByTestId('timer')).toBeTruthy();
206
+ });
207
+ });
208
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astacinco/rn-primitives",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Theme-aware UI primitives for React Native",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -21,7 +21,13 @@
21
21
  },
22
22
  "peerDependencies": {
23
23
  "react": ">=18.0.0",
24
- "react-native": ">=0.72.0"
24
+ "react-native": ">=0.72.0",
25
+ "react-native-markdown-display": ">=7.0.0"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "react-native-markdown-display": {
29
+ "optional": true
30
+ }
25
31
  },
26
32
  "dependencies": {
27
33
  "@astacinco/rn-theming": "*"
@@ -29,10 +35,8 @@
29
35
  "devDependencies": {
30
36
  "@astacinco/rn-testing": "*",
31
37
  "@testing-library/react-native": "^12.4.3",
32
- "@types/react": "^18.2.48",
33
- "react": "18.2.0",
34
- "react-native": "0.73.4",
35
- "react-test-renderer": "18.2.0",
38
+ "@types/react": "~19.1.0",
39
+ "react-test-renderer": "19.1.0",
36
40
  "typescript": "^5.3.3"
37
41
  },
38
42
  "keywords": [
@@ -0,0 +1,113 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet, Pressable, Linking } from 'react-native';
3
+ import { useTheme } from '@astacinco/rn-theming';
4
+ import { Text } from '../Text';
5
+ import { HStack } from '../Stack';
6
+ import type { AppFooterProps } from './types';
7
+
8
+ /**
9
+ * AppFooter - Consistent footer across apps
10
+ *
11
+ * Features:
12
+ * - SparkLabs branding with Patreon link
13
+ * - Social links (GitHub, YouTube, etc.)
14
+ * - Copyright
15
+ * - Optional version info
16
+ */
17
+ export function AppFooter({
18
+ showPatreonLink = true,
19
+ showGitHub = true,
20
+ showCopyright = true,
21
+ version,
22
+ customLinks,
23
+ testID,
24
+ }: AppFooterProps) {
25
+ const { colors, spacing } = useTheme();
26
+
27
+ const handleLink = (url: string) => {
28
+ Linking.openURL(url);
29
+ };
30
+
31
+ return (
32
+ <View
33
+ testID={testID}
34
+ style={[
35
+ styles.container,
36
+ {
37
+ backgroundColor: colors.surface,
38
+ borderTopColor: colors.border,
39
+ paddingHorizontal: spacing.md,
40
+ paddingVertical: spacing.md,
41
+ },
42
+ ]}
43
+ >
44
+ {/* Links Row */}
45
+ <HStack justify="center" spacing="lg" style={styles.linksRow}>
46
+ {showPatreonLink && (
47
+ <Pressable onPress={() => handleLink('https://patreon.com/SparkLabs343')}>
48
+ <Text variant="caption" color={colors.primary}>
49
+ ⚡ SparkLabs343
50
+ </Text>
51
+ </Pressable>
52
+ )}
53
+
54
+ {showGitHub && (
55
+ <Pressable onPress={() => handleLink('https://github.com/jrudydev')}>
56
+ <Text variant="caption" color={colors.textSecondary}>
57
+ GitHub
58
+ </Text>
59
+ </Pressable>
60
+ )}
61
+
62
+ {customLinks?.map((link, index) => (
63
+ <Pressable key={index} onPress={() => handleLink(link.url)}>
64
+ <Text variant="caption" color={colors.textSecondary}>
65
+ {link.label}
66
+ </Text>
67
+ </Pressable>
68
+ ))}
69
+ </HStack>
70
+
71
+ {/* Bottom Row */}
72
+ <HStack justify="center" spacing="sm" style={styles.bottomRow}>
73
+ {showCopyright && (
74
+ <Text variant="caption" color={colors.textMuted}>
75
+ © {new Date().getFullYear()} Rudy Gomez
76
+ </Text>
77
+ )}
78
+
79
+ {version && (
80
+ <Text variant="caption" color={colors.textMuted}>
81
+ · v{version}
82
+ </Text>
83
+ )}
84
+ </HStack>
85
+
86
+ {/* Powered by */}
87
+ <View style={styles.poweredBy}>
88
+ <Text variant="caption" color={colors.textMuted} style={styles.poweredByText}>
89
+ Built with @astacinco packages
90
+ </Text>
91
+ </View>
92
+ </View>
93
+ );
94
+ }
95
+
96
+ const styles = StyleSheet.create({
97
+ container: {
98
+ borderTopWidth: 1,
99
+ },
100
+ linksRow: {
101
+ marginBottom: 8,
102
+ },
103
+ bottomRow: {
104
+ marginBottom: 4,
105
+ },
106
+ poweredBy: {
107
+ alignItems: 'center',
108
+ },
109
+ poweredByText: {
110
+ fontSize: 10,
111
+ letterSpacing: 1,
112
+ },
113
+ });
@@ -0,0 +1,2 @@
1
+ export { AppFooter } from './AppFooter';
2
+ export type { AppFooterProps, FooterLink } from './types';
@@ -0,0 +1,39 @@
1
+ export interface FooterLink {
2
+ label: string;
3
+ url: string;
4
+ }
5
+
6
+ export interface AppFooterProps {
7
+ /**
8
+ * Show Patreon/SparkLabs link
9
+ * @default true
10
+ */
11
+ showPatreonLink?: boolean;
12
+
13
+ /**
14
+ * Show GitHub link
15
+ * @default true
16
+ */
17
+ showGitHub?: boolean;
18
+
19
+ /**
20
+ * Show copyright
21
+ * @default true
22
+ */
23
+ showCopyright?: boolean;
24
+
25
+ /**
26
+ * App version to display
27
+ */
28
+ version?: string;
29
+
30
+ /**
31
+ * Custom links to display
32
+ */
33
+ customLinks?: FooterLink[];
34
+
35
+ /**
36
+ * Test ID for testing
37
+ */
38
+ testID?: string;
39
+ }
@@ -0,0 +1,165 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet, Pressable } from 'react-native';
3
+ import { useTheme } from '@astacinco/rn-theming';
4
+ import { Text } from '../Text';
5
+ import { HStack } from '../Stack';
6
+ import { Avatar } from '../Avatar';
7
+ import type { AppHeaderProps } from './types';
8
+
9
+ /**
10
+ * AppHeader - Consistent header across apps
11
+ *
12
+ * Features:
13
+ * - App title with optional glow effect
14
+ * - Theme variant toggle (default/sparklabs)
15
+ * - Theme mode toggle (light/dark)
16
+ * - Optional profile button (top right)
17
+ * - Optional custom actions
18
+ */
19
+ export function AppHeader({
20
+ title = 'SparkLabs',
21
+ subtitle,
22
+ showThemeToggle = true,
23
+ showThemeVariant = false,
24
+ themeVariant = 'default',
25
+ onThemeVariantChange,
26
+ onThemeChange,
27
+ showProfile = false,
28
+ profileImageUrl,
29
+ profileFallback = '?',
30
+ onProfilePress,
31
+ glow = false,
32
+ actions,
33
+ testID,
34
+ }: AppHeaderProps) {
35
+ const { colors, mode, setMode, spacing } = useTheme();
36
+
37
+ const handleThemeToggle = () => {
38
+ const newMode = mode === 'light' ? 'dark' : 'light';
39
+ setMode(newMode);
40
+ onThemeChange?.(newMode);
41
+ };
42
+
43
+ const handleThemeVariantToggle = () => {
44
+ const newVariant = themeVariant === 'default' ? 'sparklabs' : 'default';
45
+ onThemeVariantChange?.(newVariant);
46
+ };
47
+
48
+ const glowStyle = glow && mode === 'dark' ? {
49
+ textShadowColor: colors.primary,
50
+ textShadowOffset: { width: 0, height: 0 },
51
+ textShadowRadius: 10,
52
+ } : {};
53
+
54
+ return (
55
+ <View
56
+ testID={testID}
57
+ style={[
58
+ styles.container,
59
+ {
60
+ backgroundColor: colors.surface,
61
+ borderBottomColor: colors.border,
62
+ paddingHorizontal: spacing.md,
63
+ paddingVertical: spacing.sm,
64
+ },
65
+ ]}
66
+ >
67
+ <HStack justify="space-between" align="center">
68
+ {/* Left: Title */}
69
+ <View style={styles.titleContainer}>
70
+ <Text
71
+ variant="subtitle"
72
+ style={[
73
+ styles.title,
74
+ glowStyle,
75
+ { color: glow && mode === 'dark' ? colors.primary : colors.text },
76
+ ]}
77
+ >
78
+ {title}
79
+ </Text>
80
+ {subtitle && (
81
+ <Text variant="caption" color={colors.textMuted}>
82
+ {subtitle}
83
+ </Text>
84
+ )}
85
+ </View>
86
+
87
+ {/* Right: Actions */}
88
+ <HStack spacing="sm" align="center">
89
+ {/* Custom actions */}
90
+ {actions}
91
+
92
+ {/* Theme variant toggle */}
93
+ {showThemeVariant && (
94
+ <Pressable
95
+ onPress={handleThemeVariantToggle}
96
+ style={({ pressed }) => [
97
+ styles.iconButton,
98
+ {
99
+ backgroundColor: pressed ? colors.backgroundSecondary : 'transparent',
100
+ borderColor: colors.border,
101
+ },
102
+ ]}
103
+ accessibilityLabel={`Switch to ${themeVariant === 'default' ? 'SparkLabs' : 'Default'} theme`}
104
+ accessibilityRole="button"
105
+ >
106
+ <Text variant="caption" color={colors.primary}>
107
+ {themeVariant === 'default' ? '✨ Spark' : '🎨 Default'}
108
+ </Text>
109
+ </Pressable>
110
+ )}
111
+
112
+ {/* Light/dark mode toggle */}
113
+ {showThemeToggle && (
114
+ <Pressable
115
+ onPress={handleThemeToggle}
116
+ style={({ pressed }) => [
117
+ styles.iconButton,
118
+ {
119
+ backgroundColor: pressed ? colors.backgroundSecondary : 'transparent',
120
+ borderColor: colors.border,
121
+ },
122
+ ]}
123
+ accessibilityLabel={`Switch to ${mode === 'light' ? 'dark' : 'light'} mode`}
124
+ accessibilityRole="button"
125
+ >
126
+ <Text variant="body">
127
+ {mode === 'light' ? '🌙' : '☀️'}
128
+ </Text>
129
+ </Pressable>
130
+ )}
131
+
132
+ {/* Profile button */}
133
+ {showProfile && (
134
+ <Pressable onPress={onProfilePress}>
135
+ <Avatar
136
+ source={profileImageUrl ? { uri: profileImageUrl } : undefined}
137
+ fallback={profileFallback}
138
+ size="sm"
139
+ />
140
+ </Pressable>
141
+ )}
142
+ </HStack>
143
+ </HStack>
144
+ </View>
145
+ );
146
+ }
147
+
148
+ const styles = StyleSheet.create({
149
+ container: {
150
+ borderBottomWidth: 1,
151
+ },
152
+ titleContainer: {
153
+ flexDirection: 'column',
154
+ },
155
+ title: {
156
+ fontWeight: '700',
157
+ letterSpacing: 1,
158
+ },
159
+ iconButton: {
160
+ paddingHorizontal: 12,
161
+ paddingVertical: 8,
162
+ borderRadius: 6,
163
+ borderWidth: 1,
164
+ },
165
+ });
@@ -0,0 +1,2 @@
1
+ export { AppHeader } from './AppHeader';
2
+ export type { AppHeaderProps, ThemeVariant } from './types';
@@ -0,0 +1,82 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { ResolvedThemeMode } from '@astacinco/rn-theming';
3
+
4
+ export type ThemeVariant = 'default' | 'sparklabs';
5
+
6
+ export interface AppHeaderProps {
7
+ /**
8
+ * App title displayed in the header
9
+ * @default 'SparkLabs'
10
+ */
11
+ title?: string;
12
+
13
+ /**
14
+ * Optional subtitle below the title
15
+ */
16
+ subtitle?: string;
17
+
18
+ /**
19
+ * Show light/dark mode toggle
20
+ * @default true
21
+ */
22
+ showThemeToggle?: boolean;
23
+
24
+ /**
25
+ * Show theme variant toggle (default/sparklabs)
26
+ * @default false
27
+ */
28
+ showThemeVariant?: boolean;
29
+
30
+ /**
31
+ * Current theme variant (required if showThemeVariant is true)
32
+ */
33
+ themeVariant?: ThemeVariant;
34
+
35
+ /**
36
+ * Callback when theme variant changes
37
+ */
38
+ onThemeVariantChange?: (variant: ThemeVariant) => void;
39
+
40
+ /**
41
+ * Callback when theme mode changes
42
+ */
43
+ onThemeChange?: (mode: ResolvedThemeMode) => void;
44
+
45
+ /**
46
+ * Show profile button (top right)
47
+ * @default false
48
+ */
49
+ showProfile?: boolean;
50
+
51
+ /**
52
+ * Profile button image URL (optional, shows initials/? if not provided)
53
+ */
54
+ profileImageUrl?: string;
55
+
56
+ /**
57
+ * Profile fallback text (initials or ?)
58
+ * @default '?'
59
+ */
60
+ profileFallback?: string;
61
+
62
+ /**
63
+ * Callback when profile button is pressed
64
+ */
65
+ onProfilePress?: () => void;
66
+
67
+ /**
68
+ * Enable glow effect on title (SparkLabs aesthetic)
69
+ * @default false
70
+ */
71
+ glow?: boolean;
72
+
73
+ /**
74
+ * Custom action buttons to display (in addition to built-in actions)
75
+ */
76
+ actions?: ReactNode;
77
+
78
+ /**
79
+ * Test ID for testing
80
+ */
81
+ testID?: string;
82
+ }