@astacinco/rn-primitives 0.1.0 → 0.3.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 (65) hide show
  1. package/README.md +195 -0
  2. package/__tests__/Button.test.tsx +3 -1
  3. package/__tests__/Card.test.tsx +18 -1
  4. package/__tests__/Container.test.tsx +18 -1
  5. package/__tests__/Input.test.tsx +3 -1
  6. package/__tests__/Stack.test.tsx +26 -4
  7. package/__tests__/Tabs.test.tsx +196 -0
  8. package/__tests__/Tag.test.tsx +125 -0
  9. package/__tests__/Text.test.tsx +3 -1
  10. package/__tests__/Timer.test.tsx +210 -0
  11. package/markdown/README.md +66 -0
  12. package/markdown/md.d.ts +16 -0
  13. package/markdown/metro-md-transformer.js +41 -0
  14. package/package.json +11 -7
  15. package/src/Accordion/Accordion.tsx +69 -0
  16. package/src/Accordion/AccordionItem.tsx +130 -0
  17. package/src/Accordion/index.ts +3 -0
  18. package/src/Accordion/types.ts +40 -0
  19. package/src/AppFooter/AppFooter.tsx +113 -0
  20. package/src/AppFooter/index.ts +2 -0
  21. package/src/AppFooter/types.ts +39 -0
  22. package/src/AppHeader/AppHeader.tsx +191 -0
  23. package/src/AppHeader/index.ts +2 -0
  24. package/src/AppHeader/types.ts +93 -0
  25. package/src/Avatar/Avatar.tsx +111 -0
  26. package/src/Avatar/index.ts +2 -0
  27. package/src/Avatar/types.ts +63 -0
  28. package/src/Badge/Badge.tsx +150 -0
  29. package/src/Badge/index.ts +2 -0
  30. package/src/Badge/types.ts +93 -0
  31. package/src/Button/Button.tsx +34 -20
  32. package/src/Button/types.ts +1 -1
  33. package/src/FloatingTierBadge/FloatingTierBadge.tsx +100 -0
  34. package/src/FloatingTierBadge/index.ts +2 -0
  35. package/src/FloatingTierBadge/types.ts +29 -0
  36. package/src/Input/Input.tsx +8 -23
  37. package/src/MarkdownViewer/MarkdownViewer.tsx +185 -0
  38. package/src/MarkdownViewer/index.ts +2 -0
  39. package/src/MarkdownViewer/types.ts +18 -0
  40. package/src/Modal/Modal.tsx +136 -0
  41. package/src/Modal/index.ts +2 -0
  42. package/src/Modal/types.ts +68 -0
  43. package/src/ProBadge/ProBadge.tsx +59 -0
  44. package/src/ProBadge/index.ts +2 -0
  45. package/src/ProBadge/types.ts +13 -0
  46. package/src/ProLockOverlay/ProLockOverlay.tsx +137 -0
  47. package/src/ProLockOverlay/index.ts +2 -0
  48. package/src/ProLockOverlay/types.ts +28 -0
  49. package/src/Switch/Switch.tsx +120 -0
  50. package/src/Switch/index.ts +2 -0
  51. package/src/Switch/types.ts +58 -0
  52. package/src/TabView/TabPanel.tsx +18 -0
  53. package/src/TabView/TabView.tsx +81 -0
  54. package/src/TabView/index.ts +3 -0
  55. package/src/TabView/types.ts +39 -0
  56. package/src/Tabs/Tabs.tsx +137 -0
  57. package/src/Tabs/index.ts +2 -0
  58. package/src/Tabs/types.ts +66 -0
  59. package/src/Tag/Tag.tsx +100 -0
  60. package/src/Tag/index.ts +2 -0
  61. package/src/Tag/types.ts +42 -0
  62. package/src/Timer/Timer.tsx +170 -0
  63. package/src/Timer/index.ts +2 -0
  64. package/src/Timer/types.ts +69 -0
  65. package/src/index.ts +60 -0
@@ -0,0 +1,150 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { useTheme } from '@astacinco/rn-theming';
4
+ import { Text } from '../Text';
5
+ import type { BadgeProps, BadgeVariant, BadgePosition, BadgeSize } from './types';
6
+
7
+ const sizeConfig: Record<BadgeSize, { minWidth: number; height: number; fontSize: number; dotSize: number; padding: number }> = {
8
+ sm: { minWidth: 16, height: 16, fontSize: 10, dotSize: 8, padding: 4 },
9
+ md: { minWidth: 20, height: 20, fontSize: 12, dotSize: 10, padding: 6 },
10
+ lg: { minWidth: 24, height: 24, fontSize: 14, dotSize: 12, padding: 8 },
11
+ };
12
+
13
+ const getPositionStyle = (
14
+ position: BadgePosition,
15
+ offset: [number, number]
16
+ ): { top?: number; bottom?: number; left?: number; right?: number } => {
17
+ const [offsetX, offsetY] = offset;
18
+
19
+ switch (position) {
20
+ case 'top-right':
21
+ return { top: offsetY, right: offsetX };
22
+ case 'top-left':
23
+ return { top: offsetY, left: offsetX };
24
+ case 'bottom-right':
25
+ return { bottom: offsetY, right: offsetX };
26
+ case 'bottom-left':
27
+ return { bottom: offsetY, left: offsetX };
28
+ }
29
+ };
30
+
31
+ export function Badge({
32
+ children,
33
+ count,
34
+ maxCount = 99,
35
+ dot = false,
36
+ variant = 'error',
37
+ position = 'top-right',
38
+ size = 'md',
39
+ showZero = false,
40
+ label,
41
+ offset = [0, 0],
42
+ hidden = false,
43
+ standalone = false,
44
+ style,
45
+ badgeStyle,
46
+ testID,
47
+ }: BadgeProps) {
48
+ const { colors } = useTheme();
49
+ const config = sizeConfig[size];
50
+
51
+ const getVariantColor = (v: BadgeVariant): string => {
52
+ switch (v) {
53
+ case 'primary':
54
+ return colors.primary;
55
+ case 'error':
56
+ return colors.error;
57
+ case 'success':
58
+ return colors.success;
59
+ case 'warning':
60
+ return colors.warning;
61
+ case 'default':
62
+ default:
63
+ return colors.textSecondary;
64
+ }
65
+ };
66
+
67
+ // Determine if badge should be visible
68
+ const shouldShow = !hidden && (
69
+ dot ||
70
+ label ||
71
+ (count !== undefined && (count > 0 || showZero))
72
+ );
73
+
74
+ // Format count text
75
+ const getCountText = (): string => {
76
+ if (label) return label;
77
+ if (count === undefined) return '';
78
+ if (count > maxCount) return `${maxCount}+`;
79
+ return count.toString();
80
+ };
81
+
82
+ const badgeColor = getVariantColor(variant);
83
+ const countText = getCountText();
84
+
85
+ const badgeElement = shouldShow ? (
86
+ <View
87
+ testID={testID}
88
+ style={[
89
+ styles.badge,
90
+ dot ? {
91
+ width: config.dotSize,
92
+ height: config.dotSize,
93
+ borderRadius: config.dotSize / 2,
94
+ backgroundColor: badgeColor,
95
+ } : {
96
+ minWidth: config.minWidth,
97
+ height: config.height,
98
+ borderRadius: config.height / 2,
99
+ backgroundColor: badgeColor,
100
+ paddingHorizontal: config.padding,
101
+ },
102
+ !standalone && getPositionStyle(position, offset),
103
+ !standalone && styles.positioned,
104
+ badgeStyle,
105
+ ]}
106
+ >
107
+ {!dot && (
108
+ <Text
109
+ variant="caption"
110
+ style={{
111
+ color: '#FFFFFF',
112
+ fontSize: config.fontSize,
113
+ fontWeight: '600',
114
+ textAlign: 'center',
115
+ }}
116
+ >
117
+ {countText}
118
+ </Text>
119
+ )}
120
+ </View>
121
+ ) : null;
122
+
123
+ // Standalone badge (no wrapper)
124
+ if (standalone || !children) {
125
+ return badgeElement;
126
+ }
127
+
128
+ // Wrapper badge
129
+ return (
130
+ <View style={[styles.container, style]}>
131
+ {children}
132
+ {badgeElement}
133
+ </View>
134
+ );
135
+ }
136
+
137
+ const styles = StyleSheet.create({
138
+ container: {
139
+ position: 'relative',
140
+ alignSelf: 'flex-start',
141
+ },
142
+ badge: {
143
+ alignItems: 'center',
144
+ justifyContent: 'center',
145
+ },
146
+ positioned: {
147
+ position: 'absolute',
148
+ zIndex: 1,
149
+ },
150
+ });
@@ -0,0 +1,2 @@
1
+ export { Badge } from './Badge';
2
+ export type { BadgeProps, BadgeVariant, BadgePosition, BadgeSize } from './types';
@@ -0,0 +1,93 @@
1
+ import type { ViewStyle, StyleProp } from 'react-native';
2
+ import type { ReactNode } from 'react';
3
+
4
+ export type BadgeVariant = 'default' | 'primary' | 'error' | 'success' | 'warning';
5
+ export type BadgePosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
6
+ export type BadgeSize = 'sm' | 'md' | 'lg';
7
+
8
+ export interface BadgeProps {
9
+ /**
10
+ * Content to wrap with badge
11
+ */
12
+ children?: ReactNode;
13
+
14
+ /**
15
+ * Badge count (displays number)
16
+ * If > maxCount, shows "maxCount+"
17
+ */
18
+ count?: number;
19
+
20
+ /**
21
+ * Maximum count to display
22
+ * @default 99
23
+ */
24
+ maxCount?: number;
25
+
26
+ /**
27
+ * Show as dot instead of count
28
+ * @default false
29
+ */
30
+ dot?: boolean;
31
+
32
+ /**
33
+ * Badge variant (color scheme)
34
+ * @default 'error'
35
+ */
36
+ variant?: BadgeVariant;
37
+
38
+ /**
39
+ * Badge position relative to children
40
+ * @default 'top-right'
41
+ */
42
+ position?: BadgePosition;
43
+
44
+ /**
45
+ * Badge size
46
+ * @default 'md'
47
+ */
48
+ size?: BadgeSize;
49
+
50
+ /**
51
+ * Show badge even when count is 0
52
+ * @default false
53
+ */
54
+ showZero?: boolean;
55
+
56
+ /**
57
+ * Custom badge label (overrides count)
58
+ */
59
+ label?: string;
60
+
61
+ /**
62
+ * Offset from corner [x, y]
63
+ * @default [0, 0]
64
+ */
65
+ offset?: [number, number];
66
+
67
+ /**
68
+ * Hide the badge
69
+ * @default false
70
+ */
71
+ hidden?: boolean;
72
+
73
+ /**
74
+ * Standalone badge (no children)
75
+ * @default false
76
+ */
77
+ standalone?: boolean;
78
+
79
+ /**
80
+ * Custom container style
81
+ */
82
+ style?: StyleProp<ViewStyle>;
83
+
84
+ /**
85
+ * Custom badge style
86
+ */
87
+ badgeStyle?: StyleProp<ViewStyle>;
88
+
89
+ /**
90
+ * Test ID for testing
91
+ */
92
+ testID?: string;
93
+ }
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
- import { Pressable, Text, StyleSheet, ActivityIndicator, ViewStyle, StyleProp } from 'react-native';
2
+ import { Pressable, StyleSheet, ActivityIndicator, ViewStyle, StyleProp } from 'react-native';
3
3
  import { useTheme } from '@astacinco/rn-theming';
4
+ import { Text } from '../Text';
4
5
  import type { ButtonProps, ButtonVariant, ButtonSize } from './types';
5
6
 
6
7
  const sizeStyles: Record<ButtonSize, { paddingVertical: number; paddingHorizontal: number; fontSize: number }> = {
@@ -23,7 +24,7 @@ export function Button({
23
24
  const { colors } = useTheme();
24
25
  const sizeStyle = sizeStyles[size];
25
26
 
26
- const getVariantStyles = (v: ButtonVariant, pressed: boolean) => {
27
+ const getVariantStyles = (v: ButtonVariant, pressed: boolean, hovered: boolean) => {
27
28
  const opacity = pressed ? 0.8 : 1;
28
29
 
29
30
  switch (v) {
@@ -33,6 +34,8 @@ export function Button({
33
34
  borderWidth: 0,
34
35
  textColor: colors.textInverse,
35
36
  opacity,
37
+ // Web hover: slight scale up
38
+ transform: hovered && !pressed ? [{ scale: 1.02 }] : undefined,
36
39
  };
37
40
  case 'secondary':
38
41
  return {
@@ -40,21 +43,32 @@ export function Button({
40
43
  borderWidth: 0,
41
44
  textColor: colors.textInverse,
42
45
  opacity,
46
+ transform: hovered && !pressed ? [{ scale: 1.02 }] : undefined,
43
47
  };
44
48
  case 'outline':
45
49
  return {
46
- backgroundColor: 'transparent',
50
+ backgroundColor: hovered ? colors.primary + '10' : 'transparent',
47
51
  borderWidth: 1,
48
52
  borderColor: colors.primary,
49
53
  textColor: colors.primary,
50
54
  opacity,
55
+ transform: undefined,
51
56
  };
52
57
  case 'ghost':
53
58
  return {
54
- backgroundColor: pressed ? colors.surface : 'transparent',
59
+ backgroundColor: pressed ? colors.surface : hovered ? colors.surface : 'transparent',
55
60
  borderWidth: 0,
56
61
  textColor: colors.primary,
57
62
  opacity: 1,
63
+ transform: undefined,
64
+ };
65
+ case 'danger':
66
+ return {
67
+ backgroundColor: colors.error,
68
+ borderWidth: 0,
69
+ textColor: colors.textInverse,
70
+ opacity,
71
+ transform: hovered && !pressed ? [{ scale: 1.02 }] : undefined,
58
72
  };
59
73
  }
60
74
  };
@@ -66,8 +80,9 @@ export function Button({
66
80
  testID={testID}
67
81
  onPress={onPress}
68
82
  disabled={isDisabled}
69
- style={({ pressed }): StyleProp<ViewStyle> => {
70
- const variantStyle = getVariantStyles(variant, pressed);
83
+ // @ts-expect-error - cursor is web-only, ignored on native
84
+ style={({ pressed, hovered }): StyleProp<ViewStyle> => {
85
+ const variantStyle = getVariantStyles(variant, pressed, hovered ?? false);
71
86
  return [
72
87
  styles.base,
73
88
  {
@@ -77,25 +92,28 @@ export function Button({
77
92
  paddingVertical: sizeStyle.paddingVertical,
78
93
  paddingHorizontal: sizeStyle.paddingHorizontal,
79
94
  opacity: isDisabled ? 0.5 : variantStyle.opacity,
80
- },
95
+ transform: variantStyle.transform,
96
+ // Web-only: cursor style (ignored on native)
97
+ cursor: isDisabled ? 'not-allowed' : 'pointer',
98
+ } as ViewStyle,
81
99
  style as ViewStyle,
82
100
  ];
83
101
  }}
84
102
  {...props}
85
103
  >
86
- {({ pressed }) => {
87
- const variantStyle = getVariantStyles(variant, pressed);
104
+ {({ pressed, hovered }) => {
105
+ const variantStyle = getVariantStyles(variant, pressed, hovered ?? false);
88
106
  return loading ? (
89
107
  <ActivityIndicator color={variantStyle.textColor} size="small" />
90
108
  ) : (
91
109
  <Text
92
- style={[
93
- styles.label,
94
- {
95
- color: variantStyle.textColor,
96
- fontSize: sizeStyle.fontSize,
97
- },
98
- ]}
110
+ variant="body"
111
+ style={{
112
+ color: variantStyle.textColor,
113
+ fontSize: sizeStyle.fontSize,
114
+ fontWeight: '600',
115
+ textAlign: 'center',
116
+ }}
99
117
  >
100
118
  {label}
101
119
  </Text>
@@ -112,8 +130,4 @@ const styles = StyleSheet.create({
112
130
  justifyContent: 'center',
113
131
  flexDirection: 'row',
114
132
  },
115
- label: {
116
- fontWeight: '600',
117
- textAlign: 'center',
118
- },
119
133
  });
@@ -1,6 +1,6 @@
1
1
  import type { PressableProps } from 'react-native';
2
2
 
3
- export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
3
+ export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
4
4
  export type ButtonSize = 'sm' | 'md' | 'lg';
5
5
 
6
6
  export interface ButtonProps extends Omit<PressableProps, 'children'> {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * FloatingTierBadge component
3
+ *
4
+ * A floating badge that appears in the corner of the screen
5
+ * to indicate pro tier status. Uses themed colors with a
6
+ * subtle glow effect inspired by the SparkLabs design.
7
+ */
8
+
9
+ import React from 'react';
10
+ import { StyleSheet, Pressable, View } from 'react-native';
11
+ import { useTheme } from '@astacinco/rn-theming';
12
+ import { Text } from '../Text';
13
+ import type { FloatingTierBadgeProps, TierBadgePosition } from './types';
14
+
15
+ const positionStyles: Record<TierBadgePosition, object> = {
16
+ 'top-right': { top: 16, right: 16 },
17
+ 'top-left': { top: 16, left: 16 },
18
+ 'bottom-right': { bottom: 16, right: 16 },
19
+ 'bottom-left': { bottom: 16, left: 16 },
20
+ };
21
+
22
+ export function FloatingTierBadge({
23
+ visible,
24
+ position = 'bottom-right',
25
+ label = 'PRO',
26
+ onPress,
27
+ }: FloatingTierBadgeProps) {
28
+ const { colors, shadows } = useTheme();
29
+
30
+ if (!visible) {
31
+ return null;
32
+ }
33
+
34
+ const content = (
35
+ <View
36
+ style={[
37
+ styles.badge,
38
+ positionStyles[position],
39
+ {
40
+ backgroundColor: colors.proGlow,
41
+ borderColor: colors.pro,
42
+ // Apply shadow for glow effect
43
+ shadowColor: colors.pro,
44
+ shadowOffset: { width: 0, height: 0 },
45
+ shadowOpacity: 0.6,
46
+ shadowRadius: 8,
47
+ },
48
+ ]}
49
+ >
50
+ <Text
51
+ style={[
52
+ styles.text,
53
+ { color: colors.pro },
54
+ ]}
55
+ >
56
+ {label}
57
+ </Text>
58
+ </View>
59
+ );
60
+
61
+ if (onPress) {
62
+ return (
63
+ <Pressable
64
+ onPress={onPress}
65
+ style={({ pressed }) => [
66
+ styles.container,
67
+ positionStyles[position],
68
+ { opacity: pressed ? 0.8 : 1 },
69
+ ]}
70
+ >
71
+ {content}
72
+ </Pressable>
73
+ );
74
+ }
75
+
76
+ return (
77
+ <View style={[styles.container, positionStyles[position]]}>
78
+ {content}
79
+ </View>
80
+ );
81
+ }
82
+
83
+ const styles = StyleSheet.create({
84
+ container: {
85
+ position: 'absolute',
86
+ zIndex: 1000,
87
+ },
88
+ badge: {
89
+ paddingHorizontal: 10,
90
+ paddingVertical: 4,
91
+ borderRadius: 4,
92
+ borderWidth: 1,
93
+ },
94
+ text: {
95
+ fontSize: 10,
96
+ fontWeight: '700',
97
+ letterSpacing: 1.5,
98
+ textTransform: 'uppercase',
99
+ },
100
+ });
@@ -0,0 +1,2 @@
1
+ export { FloatingTierBadge } from './FloatingTierBadge';
2
+ export type { FloatingTierBadgeProps, TierBadgePosition } from './types';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * FloatingTierBadge types
3
+ */
4
+
5
+ export type TierBadgePosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
6
+
7
+ export interface FloatingTierBadgeProps {
8
+ /**
9
+ * Whether to show the badge (typically only for pro users)
10
+ */
11
+ visible: boolean;
12
+
13
+ /**
14
+ * Position of the floating badge
15
+ * @default 'bottom-right'
16
+ */
17
+ position?: TierBadgePosition;
18
+
19
+ /**
20
+ * Label to display
21
+ * @default 'PRO'
22
+ */
23
+ label?: string;
24
+
25
+ /**
26
+ * Optional callback when badge is pressed
27
+ */
28
+ onPress?: () => void;
29
+ }
@@ -1,6 +1,7 @@
1
1
  import React, { useState } from 'react';
2
- import { View, TextInput, Text, StyleSheet, NativeSyntheticEvent, TextInputFocusEventData } from 'react-native';
2
+ import { View, TextInput, StyleSheet, NativeSyntheticEvent, TextInputFocusEventData } from 'react-native';
3
3
  import { useTheme } from '@astacinco/rn-theming';
4
+ import { Text } from '../Text';
4
5
  import type { InputProps } from './types';
5
6
 
6
7
  export function Input({
@@ -36,14 +37,9 @@ export function Input({
36
37
  <View style={styles.container}>
37
38
  {label && (
38
39
  <Text
39
- style={[
40
- styles.label,
41
- {
42
- color: colors.textSecondary,
43
- fontSize: typography.fontSize.sm,
44
- marginBottom: spacing.xs,
45
- },
46
- ]}
40
+ variant="label"
41
+ color={colors.textSecondary}
42
+ style={{ marginBottom: spacing.xs }}
47
43
  >
48
44
  {label}
49
45
  </Text>
@@ -70,14 +66,9 @@ export function Input({
70
66
  />
71
67
  {error && (
72
68
  <Text
73
- style={[
74
- styles.error,
75
- {
76
- color: colors.error,
77
- fontSize: typography.fontSize.sm,
78
- marginTop: spacing.xs,
79
- },
80
- ]}
69
+ variant="caption"
70
+ color={colors.error}
71
+ style={{ marginTop: spacing.xs }}
81
72
  >
82
73
  {error}
83
74
  </Text>
@@ -90,14 +81,8 @@ const styles = StyleSheet.create({
90
81
  container: {
91
82
  width: '100%',
92
83
  },
93
- label: {
94
- fontWeight: '500',
95
- },
96
84
  input: {
97
85
  borderWidth: 1,
98
86
  borderRadius: 8,
99
87
  },
100
- error: {
101
- // Error text styles
102
- },
103
88
  });