@nwartz/design-system 0.1.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.
@@ -0,0 +1,202 @@
1
+ import { useState } from 'react';
2
+ import {
3
+ Pressable,
4
+ StyleSheet,
5
+ TextInput,
6
+ View,
7
+ type TextInputProps,
8
+ type ViewStyle,
9
+ type StyleProp,
10
+ } from 'react-native';
11
+
12
+ import { useTheme } from '../theme';
13
+ import { spacing } from '../tokens/spacing';
14
+ import { elevation } from '../tokens/elevation';
15
+ import { Icon, type IconName } from './Icon';
16
+ import { Text } from './Text';
17
+
18
+ export type InputFieldSize = 'sm' | 'md';
19
+
20
+ export interface InputFieldProps extends Omit<TextInputProps, 'value' | 'onChangeText'> {
21
+ label?: string;
22
+ value?: string;
23
+ onChangeText?: (text: string) => void;
24
+ size?: InputFieldSize;
25
+ disabled?: boolean;
26
+ errorMessage?: string;
27
+ leadingIcon?: IconName;
28
+ trailingIcon?: IconName;
29
+ onTrailingIconPress?: () => void;
30
+ secureTextEntry?: boolean;
31
+ }
32
+
33
+ /**
34
+ * Text input field with label, helper text, icons, and error state.
35
+ *
36
+ * ```tsx
37
+ * <InputField label="Email" placeholder="you@example.com" />
38
+ * <InputField label="Password" secureTextEntry errorMessage="Required" />
39
+ * ```
40
+ */
41
+ export function InputField({
42
+ label,
43
+ value,
44
+ onChangeText,
45
+ size = 'md',
46
+ disabled = false,
47
+ errorMessage,
48
+ leadingIcon,
49
+ trailingIcon,
50
+ onTrailingIconPress,
51
+ placeholder,
52
+ secureTextEntry,
53
+ ...rest
54
+ }: InputFieldProps) {
55
+ const theme = useTheme();
56
+ const [isFocused, setIsFocused] = useState(false);
57
+ const [isHovered, setIsHovered] = useState(false);
58
+
59
+ const hasValue = !!value && value.length > 0;
60
+ const showError = !!errorMessage;
61
+ const inputHeight = size === 'sm' ? 40 : 44;
62
+ const paddingHorizontal = size === 'sm' ? 12 : 14;
63
+
64
+ const getBorderColor = (): string => {
65
+ if (showError) {
66
+ return theme.colors.dangerMid;
67
+ }
68
+
69
+ if (isFocused) {
70
+ return '#cefe00';
71
+ }
72
+
73
+ return '#d9d8d7';
74
+ };
75
+
76
+ const getBoxShadow = (): ViewStyle => {
77
+ if (isFocused) {
78
+ return {
79
+ shadowColor: '#fff6d4',
80
+ shadowOffset: { width: 0, height: 0 },
81
+ shadowOpacity: 1,
82
+ shadowRadius: 0,
83
+ elevation: 0,
84
+ };
85
+ }
86
+
87
+ return elevation.xs;
88
+ };
89
+
90
+ const containerStyle: StyleProp<ViewStyle> = [
91
+ styles.container,
92
+ {
93
+ height: inputHeight,
94
+ paddingHorizontal,
95
+ backgroundColor: isHovered && !isFocused && !showError ? '#e9e8e7' : '#faf9f7',
96
+ borderColor: getBorderColor(),
97
+ borderWidth: 1,
98
+ borderRadius: 12,
99
+ },
100
+ getBoxShadow(),
101
+ ];
102
+
103
+ return (
104
+ <View style={[styles.wrapper, disabled && styles.disabledWrapper]}>
105
+ {label && (
106
+ <Text variant="body" textStyle={[styles.label, { color: '#737271' }]}>
107
+ {label}
108
+ </Text>
109
+ )}
110
+
111
+ <Pressable
112
+ disabled={disabled}
113
+ onHoverIn={() => setIsHovered(true)}
114
+ onHoverOut={() => setIsHovered(false)}
115
+ style={containerStyle}>
116
+ {leadingIcon && (
117
+ <Icon name={leadingIcon} size={20} color="#667085" />
118
+ )}
119
+
120
+ <TextInput
121
+ style={[
122
+ styles.input,
123
+ {
124
+ color: hasValue ? '#141414' : '#8e8d8b',
125
+ fontSize: size === 'sm' ? 13 : 14,
126
+ },
127
+ disabled && styles.inputDisabled,
128
+ ]}
129
+ value={value}
130
+ onChangeText={onChangeText}
131
+ placeholder={placeholder}
132
+ placeholderTextColor="#8e8d8b"
133
+ editable={!disabled}
134
+ onFocus={() => setIsFocused(true)}
135
+ onBlur={() => setIsFocused(false)}
136
+ secureTextEntry={secureTextEntry}
137
+ {...rest}
138
+ />
139
+
140
+ {trailingIcon && (
141
+ <Pressable
142
+ onPress={onTrailingIconPress}
143
+ disabled={!onTrailingIconPress}
144
+ hitSlop={8}
145
+ style={styles.iconButton}>
146
+ <Icon name={trailingIcon} size={20} color="#737271" />
147
+ </Pressable>
148
+ )}
149
+ </Pressable>
150
+
151
+ {showError && (
152
+ <View style={styles.errorRow}>
153
+ <Icon name="warning-circle" size={16} color={theme.colors.dangerMid} />
154
+ <Text variant="body" textStyle={[styles.errorText, { color: '#737271' }]}>
155
+ {errorMessage}
156
+ </Text>
157
+ </View>
158
+ )}
159
+ </View>
160
+ );
161
+ }
162
+
163
+ const styles = StyleSheet.create({
164
+ wrapper: {
165
+ gap: 4,
166
+ },
167
+ disabledWrapper: {
168
+ opacity: 0.32,
169
+ },
170
+ label: {
171
+ fontSize: 14,
172
+ lineHeight: 21,
173
+ fontWeight: '500',
174
+ },
175
+ container: {
176
+ flexDirection: 'row',
177
+ alignItems: 'center',
178
+ gap: 8,
179
+ },
180
+ input: {
181
+ flex: 1,
182
+ paddingVertical: 0,
183
+ includeFontPadding: false,
184
+ fontSize: 14,
185
+ lineHeight: 21,
186
+ },
187
+ inputDisabled: {
188
+ opacity: 1,
189
+ },
190
+ iconButton: {
191
+ padding: 2,
192
+ },
193
+ errorRow: {
194
+ flexDirection: 'row',
195
+ alignItems: 'center',
196
+ gap: spacing.xs,
197
+ },
198
+ errorText: {
199
+ fontSize: 14,
200
+ lineHeight: 21,
201
+ },
202
+ });
@@ -0,0 +1,69 @@
1
+ import { Pressable, StyleSheet, type PressableProps } from 'react-native';
2
+
3
+ import { spacing } from '../tokens/spacing';
4
+ import { Icon, type IconName } from './Icon';
5
+ import { Text } from './Text';
6
+
7
+ export interface LinkButtonProps extends Omit<PressableProps, 'style'> {
8
+ label: string;
9
+ icon?: IconName;
10
+ color?: 'primary' | 'secondary';
11
+ }
12
+
13
+ /**
14
+ * Text-styled button that looks like a hyperlink, with optional icon.
15
+ *
16
+ * ```tsx
17
+ * <LinkButton label="Forgot password?" />
18
+ * <LinkButton label="View all" icon="caret-right" />
19
+ * ```
20
+ */
21
+ export function LinkButton({
22
+ label,
23
+ icon,
24
+ color = 'primary',
25
+ disabled,
26
+ ...rest
27
+ }: LinkButtonProps) {
28
+ const textColor = color === 'primary' ? '#cefe00' : '#737271';
29
+
30
+ return (
31
+ <Pressable
32
+ accessibilityRole="button"
33
+ disabled={disabled}
34
+ style={({ pressed }) => [
35
+ styles.container,
36
+ pressed && styles.pressed,
37
+ disabled && styles.disabled,
38
+ ]}
39
+ {...rest}>
40
+ {icon && <Icon name={icon} size={16} color={textColor} />}
41
+ <Text
42
+ variant="body"
43
+ textStyle={[styles.label, { color: textColor }]}>
44
+ {label}
45
+ </Text>
46
+ </Pressable>
47
+ );
48
+ }
49
+
50
+ const styles = StyleSheet.create({
51
+ container: {
52
+ flexDirection: 'row',
53
+ alignItems: 'center',
54
+ gap: spacing.xs,
55
+ alignSelf: 'flex-start',
56
+ paddingVertical: spacing.xs,
57
+ },
58
+ pressed: {
59
+ opacity: 0.6,
60
+ },
61
+ disabled: {
62
+ opacity: 0.32,
63
+ },
64
+ label: {
65
+ fontSize: 14,
66
+ lineHeight: 21,
67
+ fontWeight: '500',
68
+ },
69
+ });
@@ -0,0 +1,96 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { Animated, Easing, StyleSheet, View } from 'react-native';
3
+
4
+ import { Text } from './Text';
5
+
6
+ export interface ProgressBarProps {
7
+ value: number;
8
+ showLabel?: boolean;
9
+ animated?: boolean;
10
+ }
11
+
12
+ /**
13
+ * Progress bar with percentage label.
14
+ *
15
+ * ```tsx
16
+ * <ProgressBar value={0.7} showLabel />
17
+ * <ProgressBar value={0.3} />
18
+ * ```
19
+ */
20
+ export function ProgressBar({ value, showLabel = true, animated = true }: ProgressBarProps) {
21
+ const clampedValue = Math.max(0, Math.min(1, value));
22
+ const animValue = useRef(new Animated.Value(animated ? 0 : clampedValue)).current;
23
+
24
+ useEffect(() => {
25
+ if (animated) {
26
+ Animated.timing(animValue, {
27
+ toValue: clampedValue,
28
+ duration: 400,
29
+ easing: Easing.out(Easing.cubic),
30
+ useNativeDriver: false,
31
+ }).start();
32
+ } else {
33
+ animValue.setValue(clampedValue);
34
+ }
35
+ }, [clampedValue, animated, animValue]);
36
+
37
+ const fillWidth = animValue.interpolate({
38
+ inputRange: [0, 1],
39
+ outputRange: ['0%', '100%'],
40
+ });
41
+
42
+ const percentage = Math.round(clampedValue * 100);
43
+
44
+ return (
45
+ <View style={styles.container}>
46
+ <View style={styles.barRow}>
47
+ <View style={styles.track}>
48
+ <Animated.View
49
+ style={[
50
+ styles.fill,
51
+ { width: fillWidth },
52
+ ]}
53
+ />
54
+ </View>
55
+
56
+ {showLabel && (
57
+ <Text
58
+ variant="body"
59
+ textStyle={styles.label}>
60
+ {percentage}%
61
+ </Text>
62
+ )}
63
+ </View>
64
+ </View>
65
+ );
66
+ }
67
+
68
+ const styles = StyleSheet.create({
69
+ container: {
70
+ width: '100%',
71
+ },
72
+ barRow: {
73
+ flexDirection: 'row',
74
+ alignItems: 'center',
75
+ gap: 12,
76
+ },
77
+ track: {
78
+ flex: 1,
79
+ height: 8,
80
+ borderRadius: 4,
81
+ backgroundColor: '#e9e8e7',
82
+ overflow: 'hidden',
83
+ },
84
+ fill: {
85
+ height: 8,
86
+ borderRadius: 4,
87
+ backgroundColor: '#63ca7e',
88
+ },
89
+ label: {
90
+ fontSize: 14,
91
+ lineHeight: 21,
92
+ fontWeight: '500',
93
+ color: '#737271',
94
+ minWidth: 34,
95
+ },
96
+ });
@@ -0,0 +1,147 @@
1
+ import { useState } from 'react';
2
+ import {
3
+ Pressable,
4
+ StyleSheet,
5
+ TextInput,
6
+ View,
7
+ type TextInputProps,
8
+ } from 'react-native';
9
+
10
+ import { elevation } from '../tokens/elevation';
11
+ import { Icon } from './Icon';
12
+ import { Text } from './Text';
13
+
14
+ export interface SearchBarProps extends Omit<TextInputProps, 'value' | 'onChangeText'> {
15
+ label?: string;
16
+ value?: string;
17
+ onChangeText?: (text: string) => void;
18
+ placeholder?: string;
19
+ disabled?: boolean;
20
+ onClear?: () => void;
21
+ }
22
+
23
+ /**
24
+ * Search input with magnifying glass icon, label, and clear button.
25
+ *
26
+ * ```tsx
27
+ * <SearchBar label="Search" value={query} onChangeText={setQuery} />
28
+ * ```
29
+ */
30
+ export function SearchBar({
31
+ label,
32
+ value,
33
+ onChangeText,
34
+ placeholder = 'Search',
35
+ disabled = false,
36
+ onClear,
37
+ ...rest
38
+ }: SearchBarProps) {
39
+ const [isFocused, setIsFocused] = useState(false);
40
+ const [isHovered, setIsHovered] = useState(false);
41
+
42
+ const hasValue = !!value && value.length > 0;
43
+
44
+ const getBorderColor = (): string => {
45
+ if (isFocused) {
46
+ return '#cefe00';
47
+ }
48
+
49
+ return '#d9d8d7';
50
+ };
51
+
52
+ const getBoxShadow = (): object => {
53
+ if (isFocused) {
54
+ return {
55
+ shadowColor: '#fff6d4',
56
+ shadowOffset: { width: 0, height: 0 },
57
+ shadowOpacity: 1,
58
+ shadowRadius: 0,
59
+ elevation: 0,
60
+ };
61
+ }
62
+
63
+ return elevation.xs;
64
+ };
65
+
66
+ return (
67
+ <View style={[styles.wrapper, disabled && styles.disabledWrapper]}>
68
+ {label && (
69
+ <Text variant="body" textStyle={[styles.label, { color: '#737271' }]}>
70
+ {label}
71
+ </Text>
72
+ )}
73
+
74
+ <Pressable
75
+ disabled={disabled}
76
+ onHoverIn={() => setIsHovered(true)}
77
+ onHoverOut={() => setIsHovered(false)}
78
+ style={[
79
+ styles.container,
80
+ {
81
+ backgroundColor: isHovered && !isFocused ? '#e9e8e7' : '#faf9f7',
82
+ borderColor: getBorderColor(),
83
+ borderWidth: 1,
84
+ borderRadius: 8,
85
+ },
86
+ getBoxShadow(),
87
+ ]}>
88
+ <Icon name="magnifying-glass" size={20} color="#667085" />
89
+
90
+ <TextInput
91
+ style={[styles.input, { color: hasValue ? '#141414' : '#8e8d8b' }]}
92
+ value={value}
93
+ onChangeText={onChangeText}
94
+ placeholder={placeholder}
95
+ placeholderTextColor="#8e8d8b"
96
+ editable={!disabled}
97
+ onFocus={() => setIsFocused(true)}
98
+ onBlur={() => setIsFocused(false)}
99
+ {...rest}
100
+ />
101
+
102
+ {hasValue && !disabled && (
103
+ <Pressable
104
+ onPress={() => {
105
+ onChangeText?.('');
106
+ onClear?.();
107
+ }}
108
+ hitSlop={8}
109
+ style={styles.clearButton}>
110
+ <Icon name="x-circle" size={16} color="#737271" />
111
+ </Pressable>
112
+ )}
113
+ </Pressable>
114
+ </View>
115
+ );
116
+ }
117
+
118
+ const styles = StyleSheet.create({
119
+ wrapper: {
120
+ gap: 4,
121
+ },
122
+ disabledWrapper: {
123
+ opacity: 0.32,
124
+ },
125
+ label: {
126
+ fontSize: 14,
127
+ lineHeight: 21,
128
+ fontWeight: '500',
129
+ },
130
+ container: {
131
+ flexDirection: 'row',
132
+ alignItems: 'center',
133
+ height: 44,
134
+ paddingHorizontal: 14,
135
+ gap: 8,
136
+ },
137
+ input: {
138
+ flex: 1,
139
+ paddingVertical: 0,
140
+ includeFontPadding: false,
141
+ fontSize: 14,
142
+ lineHeight: 21,
143
+ },
144
+ clearButton: {
145
+ padding: 2,
146
+ },
147
+ });
@@ -0,0 +1,88 @@
1
+ import { Pressable, StyleSheet, View } from 'react-native';
2
+
3
+ import { useTheme } from '../theme';
4
+ import { spacing } from '../tokens/spacing';
5
+ import { Text } from './Text';
6
+
7
+ export type SegmentedTabItem = {
8
+ id: string;
9
+ label: string;
10
+ };
11
+
12
+ export interface SegmentedTabsProps {
13
+ tabs: SegmentedTabItem[];
14
+ activeTabId: string;
15
+ onTabChange: (tabId: string) => void;
16
+ }
17
+
18
+ /**
19
+ * Horizontal segmented tab bar for switching between views.
20
+ *
21
+ * ```tsx
22
+ * <SegmentedTabs
23
+ * tabs={[{ id: 'feed', label: 'Feed' }, { id: 'radio', label: 'Radio PX' }]}
24
+ * activeTabId={activeTab}
25
+ * onTabChange={setActiveTab}
26
+ * />
27
+ * ```
28
+ */
29
+ export function SegmentedTabs({ tabs, activeTabId, onTabChange }: SegmentedTabsProps) {
30
+ const { colors } = useTheme();
31
+
32
+ return (
33
+ <View style={styles.container}>
34
+ {tabs.map((tab) => {
35
+ const isActive = tab.id === activeTabId;
36
+
37
+ return (
38
+ <Pressable
39
+ key={tab.id}
40
+ accessibilityRole="tab"
41
+ accessibilityState={{ selected: isActive }}
42
+ onPress={() => onTabChange(tab.id)}
43
+ style={({ pressed }) => [
44
+ styles.tab,
45
+ pressed && styles.pressed,
46
+ { borderBottomColor: isActive ? colors.primaryPure : colors.shapeTertiary },
47
+ ]}>
48
+ <Text
49
+ variant="body"
50
+ color="textPrimary"
51
+ textStyle={[
52
+ styles.label,
53
+ {
54
+ color: isActive ? colors.primaryPure : colors.textSecondary,
55
+ fontWeight: isActive ? '700' : '400',
56
+ },
57
+ ]}>
58
+ {tab.label}
59
+ </Text>
60
+ </Pressable>
61
+ );
62
+ })}
63
+ </View>
64
+ );
65
+ }
66
+
67
+ const styles = StyleSheet.create({
68
+ container: {
69
+ flexDirection: 'row',
70
+ paddingHorizontal: spacing.xs,
71
+ paddingBottom: spacing.xxs,
72
+ },
73
+ tab: {
74
+ flex: 1,
75
+ alignItems: 'center',
76
+ justifyContent: 'center',
77
+ paddingVertical: spacing.xs,
78
+ paddingBottom: spacing.sm,
79
+ borderBottomWidth: 2,
80
+ },
81
+ label: {
82
+ fontSize: 16,
83
+ lineHeight: 24,
84
+ },
85
+ pressed: {
86
+ opacity: 0.6,
87
+ },
88
+ });