@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,259 @@
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 { radius } from '../tokens/radius';
14
+ import { spacing } from '../tokens/spacing';
15
+ import { elevation } from '../tokens/elevation';
16
+ import { Icon } from './Icon';
17
+ import { Text } from './Text';
18
+ import { DropdownSelect, type DropdownItem } from './DropdownSelect';
19
+
20
+ export type AutocompleteSize = 'sm' | 'md';
21
+ export type AutocompleteState = 'default' | 'hover' | 'focus' | 'typing' | 'selected' | 'filled' | 'error' | 'disabled';
22
+
23
+ export interface AutocompleteProps extends Omit<TextInputProps, 'value' | 'onChangeText'> {
24
+ /** Label displayed above the input. */
25
+ label: string;
26
+ /** Current input value. */
27
+ value?: string;
28
+ /** Called when text changes. */
29
+ onChangeText?: (text: string) => void;
30
+ /** Input size. */
31
+ size?: AutocompleteSize;
32
+ /** Whether the input is disabled. */
33
+ disabled?: boolean;
34
+ /** Error message displayed below the input. */
35
+ errorMessage?: string;
36
+ /** Dropdown items. */
37
+ items?: DropdownItem[];
38
+ /** Selected item key. */
39
+ selectedItem?: string | null;
40
+ /** Called when a dropdown item is selected. */
41
+ onSelectItem?: (item: DropdownItem) => void;
42
+ /** Whether the dropdown is open. */
43
+ open?: boolean;
44
+ /** Called to toggle the dropdown. */
45
+ onToggle?: () => void;
46
+ }
47
+
48
+ /**
49
+ * Autocomplete input with dropdown, matching the Nwartz DS spec.
50
+ *
51
+ * Supports sizes (sm/md), states (default/hover/focus/typing/selected/filled/error/disabled),
52
+ * label, error message, and a dropdown list.
53
+ *
54
+ * ```tsx
55
+ * <Autocomplete
56
+ * label="Country"
57
+ * placeholder="Start typing..."
58
+ * items={countries}
59
+ * onSelectItem={handleSelect}
60
+ * />
61
+ * ```
62
+ */
63
+ export function Autocomplete({
64
+ label,
65
+ value,
66
+ onChangeText,
67
+ size = 'md',
68
+ disabled = false,
69
+ errorMessage,
70
+ items = [],
71
+ selectedItem,
72
+ onSelectItem,
73
+ open = false,
74
+ onToggle,
75
+ placeholder,
76
+ ...rest
77
+ }: AutocompleteProps) {
78
+ const theme = useTheme();
79
+ const [isFocused, setIsFocused] = useState(false);
80
+
81
+ const hasValue = !!value && value.length > 0;
82
+ const showError = !!errorMessage;
83
+ const isOpen = open;
84
+
85
+ const inputHeight = size === 'sm' ? 40 : 44;
86
+ const paddingHorizontal = size === 'sm'
87
+ ? spacing.md
88
+ : spacing.lg;
89
+
90
+ const getBorderColor = (): string => {
91
+ if (showError) {
92
+ return theme.colors.dangerMid;
93
+ }
94
+
95
+ if (isFocused) {
96
+ return theme.colors.primaryMid;
97
+ }
98
+
99
+ return theme.colors.shapeTertiary;
100
+ };
101
+
102
+ const getBoxShadow = (): ViewStyle => {
103
+ if (isFocused || showError) {
104
+ const focusColor = showError ? theme.colors.dangerMid : '#FFF6D4';
105
+
106
+ return {
107
+ shadowColor: focusColor,
108
+ shadowOffset: { width: 0, height: 0 },
109
+ shadowOpacity: 1,
110
+ shadowRadius: 0,
111
+ elevation: 0,
112
+ };
113
+ }
114
+
115
+ return elevation.xs;
116
+ };
117
+
118
+ const inputContainerStyle: StyleProp<ViewStyle> = [
119
+ styles.inputContainer,
120
+ {
121
+ height: inputHeight,
122
+ paddingHorizontal,
123
+ backgroundColor: theme.colors.shapePrimary,
124
+ borderColor: getBorderColor(),
125
+ borderWidth: 1,
126
+ borderRadius: radius.sm,
127
+ },
128
+ getBoxShadow(),
129
+ ];
130
+
131
+ return (
132
+ <View style={[styles.wrapper, disabled && styles.disabledWrapper]}>
133
+ <Text
134
+ variant="body"
135
+ color="textSecondary"
136
+ textStyle={styles.label}>
137
+ {label}
138
+ </Text>
139
+
140
+ <View style={inputContainerStyle}>
141
+ <TextInput
142
+ style={[
143
+ styles.input,
144
+ {
145
+ color: theme.colors.textPrimary,
146
+ fontSize: 14,
147
+ },
148
+ disabled && styles.inputDisabled,
149
+ ]}
150
+ value={value}
151
+ onChangeText={onChangeText}
152
+ placeholder={placeholder}
153
+ placeholderTextColor={theme.colors.textTertiary}
154
+ editable={!disabled}
155
+ onFocus={() => setIsFocused(true)}
156
+ onBlur={() => setIsFocused(false)}
157
+ {...rest}
158
+ />
159
+
160
+ <View style={styles.trailingIcons}>
161
+ {hasValue && !disabled && (
162
+ <Pressable
163
+ onPress={() => onChangeText?.('')}
164
+ hitSlop={8}
165
+ style={styles.iconButton}>
166
+ <Icon name="x-circle" size={16} color={theme.colors.textSecondary} />
167
+ </Pressable>
168
+ )}
169
+
170
+ <Pressable
171
+ onPress={onToggle}
172
+ disabled={disabled}
173
+ hitSlop={8}
174
+ style={styles.iconButton}>
175
+ <Icon
176
+ name={isOpen ? 'caret-up' : 'caret-down'}
177
+ size={16}
178
+ color={theme.colors.textTertiary}
179
+ />
180
+ </Pressable>
181
+ </View>
182
+ </View>
183
+
184
+ {showError && (
185
+ <View style={styles.errorRow}>
186
+ <Icon name="warning-circle" size={16} color={theme.colors.dangerMid} />
187
+ <Text
188
+ variant="body"
189
+ color="textSecondary"
190
+ textStyle={[styles.errorText, { color: theme.colors.textSecondary }]}>
191
+ {errorMessage}
192
+ </Text>
193
+ </View>
194
+ )}
195
+
196
+ {isOpen && items.length > 0 && (
197
+ <DropdownSelect
198
+ items={items}
199
+ value={selectedItem}
200
+ onSelect={(item) => {
201
+ onSelectItem?.(item);
202
+ onToggle?.();
203
+ }}
204
+ open={isOpen}
205
+ style={styles.dropdown}
206
+ />
207
+ )}
208
+ </View>
209
+ );
210
+ }
211
+
212
+ const styles = StyleSheet.create({
213
+ wrapper: {
214
+ gap: spacing.xs,
215
+ },
216
+ disabledWrapper: {
217
+ opacity: 0.32,
218
+ },
219
+ label: {
220
+ fontSize: 14,
221
+ lineHeight: 21,
222
+ fontWeight: '500',
223
+ },
224
+ inputContainer: {
225
+ flexDirection: 'row',
226
+ alignItems: 'center',
227
+ gap: spacing.sm,
228
+ },
229
+ input: {
230
+ flex: 1,
231
+ paddingVertical: 0,
232
+ includeFontPadding: false,
233
+ fontSize: 14,
234
+ lineHeight: 21,
235
+ },
236
+ inputDisabled: {
237
+ opacity: 1,
238
+ },
239
+ trailingIcons: {
240
+ flexDirection: 'row',
241
+ alignItems: 'center',
242
+ gap: spacing.xs,
243
+ },
244
+ iconButton: {
245
+ padding: 2,
246
+ },
247
+ errorRow: {
248
+ flexDirection: 'row',
249
+ alignItems: 'center',
250
+ gap: spacing.xs,
251
+ },
252
+ errorText: {
253
+ fontSize: 14,
254
+ lineHeight: 21,
255
+ },
256
+ dropdown: {
257
+ marginTop: spacing.xxs,
258
+ },
259
+ });
@@ -0,0 +1,103 @@
1
+ import { StyleSheet, View, type ViewProps } 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 type BadgeSize = 'sm' | 'md' | 'lg';
8
+ export type BadgeColor = 'primary' | 'secondary' | 'info' | 'warning' | 'success' | 'error' | 'gray';
9
+ export type BadgeMode = 'solid' | 'outline';
10
+
11
+ export interface BadgeProps extends ViewProps {
12
+ label: string;
13
+ size?: BadgeSize;
14
+ color?: BadgeColor;
15
+ mode?: BadgeMode;
16
+ icon?: IconName;
17
+ trailingIcon?: IconName;
18
+ }
19
+
20
+ const colorPalette: Record<BadgeColor, { stroke: string; solidBg: string; solidText: string; outlineText: string }> = {
21
+ primary: { stroke: '#cefe00', solidBg: '#f8ffc5', solidText: '#536e00', outlineText: '#cefe00' },
22
+ secondary: { stroke: '#4fa971', solidBg: '#ddfad9', solidText: '#25584d', outlineText: '#25584d' },
23
+ info: { stroke: '#1048d6', solidBg: '#ebf4ff', solidText: '#1048d6', outlineText: '#1048d6' },
24
+ warning: { stroke: '#e3993a', solidBg: '#fef9ec', solidText: '#642000', outlineText: '#e3993a' },
25
+ success: { stroke: '#138135', solidBg: '#f1feeb', solidText: '#05482c', outlineText: '#138135' },
26
+ error: { stroke: '#bb4355', solidBg: '#fdefed', solidText: '#bb4355', outlineText: '#bb4355' },
27
+ gray: { stroke: '#d9d8d7', solidBg: '#e9e8e7', solidText: '#737271', outlineText: '#737271' },
28
+ };
29
+
30
+ const sizeStyles = {
31
+ sm: { height: 22, paddingHorizontal: 8, fontSize: 12, lineHeight: 18, iconSize: 12 },
32
+ md: { height: 24, paddingHorizontal: 8, fontSize: 14, lineHeight: 21, iconSize: 14 },
33
+ lg: { height: 28, paddingHorizontal: 12, fontSize: 14, lineHeight: 21, iconSize: 14 },
34
+ } as const;
35
+
36
+ /**
37
+ * Inline badge for status indicators, counts, and labels.
38
+ *
39
+ * ```tsx
40
+ * <Badge label="Active" color="success" />
41
+ * <Badge label="3" color="error" size="sm" />
42
+ * <Badge label="Info" color="info" mode="outline" />
43
+ * ```
44
+ */
45
+ export function Badge({
46
+ label,
47
+ size = 'md',
48
+ color = 'gray',
49
+ mode = 'solid',
50
+ icon,
51
+ trailingIcon,
52
+ style,
53
+ ...rest
54
+ }: BadgeProps) {
55
+ const palette = colorPalette[color];
56
+ const s = sizeStyles[size];
57
+
58
+ const bgColor = mode === 'solid' ? palette.solidBg : 'transparent';
59
+ const textColor = mode === 'solid' ? palette.solidText : palette.outlineText;
60
+ const borderColor = mode === 'outline' ? palette.stroke : 'transparent';
61
+
62
+ return (
63
+ <View
64
+ style={[
65
+ styles.badge,
66
+ {
67
+ height: s.height,
68
+ paddingHorizontal: s.paddingHorizontal,
69
+ backgroundColor: bgColor,
70
+ borderColor,
71
+ borderWidth: 1,
72
+ borderRadius: 16,
73
+ },
74
+ style,
75
+ ]}
76
+ {...rest}>
77
+ {icon && <Icon name={icon} size={s.iconSize} color={textColor} />}
78
+
79
+ <Text
80
+ variant="caption"
81
+ textStyle={[
82
+ styles.label,
83
+ { color: textColor, fontSize: s.fontSize, lineHeight: s.lineHeight },
84
+ ]}>
85
+ {label}
86
+ </Text>
87
+
88
+ {trailingIcon && <Icon name={trailingIcon} size={s.iconSize} color={textColor} />}
89
+ </View>
90
+ );
91
+ }
92
+
93
+ const styles = StyleSheet.create({
94
+ badge: {
95
+ alignSelf: 'flex-start',
96
+ flexDirection: 'row',
97
+ alignItems: 'center',
98
+ gap: spacing.xs,
99
+ },
100
+ label: {
101
+ fontWeight: '500',
102
+ },
103
+ });
@@ -0,0 +1,118 @@
1
+ import { Pressable, StyleSheet, View } from 'react-native';
2
+
3
+ import { Icon, type IconName } from './Icon';
4
+
5
+ export type BottomNavBarItem = {
6
+ id: string;
7
+ icon: IconName;
8
+ badge?: number;
9
+ };
10
+
11
+ export interface BottomNavBarProps {
12
+ items: BottomNavBarItem[];
13
+ activeItemId: string;
14
+ onItemPress: (itemId: string) => void;
15
+ }
16
+
17
+ /**
18
+ * Bottom navigation bar with lime circle active item indicator.
19
+ *
20
+ * ```tsx
21
+ * <BottomNavBar
22
+ * items={[
23
+ * { id: 'home', icon: 'house' },
24
+ * { id: 'stats', icon: 'chart-bar' },
25
+ * { id: 'profile', icon: 'user' },
26
+ * ]}
27
+ * activeItemId="home"
28
+ * onItemPress={navigate}
29
+ * />
30
+ * ```
31
+ */
32
+ export function BottomNavBar({ items, activeItemId, onItemPress }: BottomNavBarProps) {
33
+ return (
34
+ <View
35
+ style={[
36
+ styles.container,
37
+ {
38
+ backgroundColor: '#201f1f' + 'CC',
39
+ borderColor: '#ffffff1A',
40
+ },
41
+ ]}>
42
+ {items.map((item) => {
43
+ const isActive = item.id === activeItemId;
44
+
45
+ return (
46
+ <Pressable
47
+ key={item.id}
48
+ accessibilityRole="button"
49
+ accessibilityState={{ selected: isActive }}
50
+ onPress={() => onItemPress(item.id)}
51
+ style={({ pressed }) => [
52
+ styles.item,
53
+ pressed && styles.pressed,
54
+ ]}>
55
+ {isActive ? (
56
+ <View style={styles.activeItem}>
57
+ <Icon
58
+ name={item.icon}
59
+ size={25}
60
+ color="#141414"
61
+ weight="regular"
62
+ />
63
+ </View>
64
+ ) : (
65
+ <View style={styles.inactiveItem}>
66
+ <Icon
67
+ name={item.icon}
68
+ size={25}
69
+ color="#8e8d8b"
70
+ weight="regular"
71
+ />
72
+ </View>
73
+ )}
74
+ </Pressable>
75
+ );
76
+ })}
77
+ </View>
78
+ );
79
+ }
80
+
81
+ const styles = StyleSheet.create({
82
+ container: {
83
+ flexDirection: 'row',
84
+ alignItems: 'center',
85
+ justifyContent: 'space-between',
86
+ padding: 12,
87
+ borderRadius: 9999,
88
+ borderWidth: 1,
89
+ },
90
+ item: {
91
+ alignItems: 'center',
92
+ justifyContent: 'center',
93
+ },
94
+ pressed: {
95
+ opacity: 0.6,
96
+ },
97
+ activeItem: {
98
+ width: 55,
99
+ height: 55,
100
+ borderRadius: 9999,
101
+ backgroundColor: '#cefe00',
102
+ alignItems: 'center',
103
+ justifyContent: 'center',
104
+ shadowColor: '#caf300',
105
+ shadowOffset: { width: 0, height: 0 },
106
+ shadowOpacity: 0.4,
107
+ shadowRadius: 15,
108
+ elevation: 8,
109
+ },
110
+ inactiveItem: {
111
+ width: 60,
112
+ height: 60,
113
+ borderRadius: 9999,
114
+ alignItems: 'center',
115
+ justifyContent: 'center',
116
+ padding: 8,
117
+ },
118
+ });
@@ -0,0 +1,99 @@
1
+ import { View, type ViewProps } from 'react-native';
2
+
3
+ import { useTheme } from '../theme';
4
+ import { spacing, type SpacingToken } from '../tokens/spacing';
5
+ import { radius, type RadiusToken } from '../tokens/radius';
6
+ import { elevation, type ElevationToken } from '../tokens/elevation';
7
+
8
+ export interface BoxProps extends ViewProps {
9
+ /** Background color token key from the resolved theme. */
10
+ bg?: 'backgroundScreen' | 'shapePrimary' | 'shapeSecondary';
11
+ /** Apply a named spacing token as padding. */
12
+ p?: SpacingToken;
13
+ /** Apply a named spacing token as horizontal padding. */
14
+ px?: SpacingToken;
15
+ /** Apply a named spacing token as vertical padding. */
16
+ py?: SpacingToken;
17
+ /** Apply a named spacing token as margin. */
18
+ m?: SpacingToken;
19
+ /** Apply a named spacing token as horizontal margin. */
20
+ mx?: SpacingToken;
21
+ /** Apply a named spacing token as vertical margin. */
22
+ my?: SpacingToken;
23
+ /** Apply a named radius token as border radius. */
24
+ rounded?: RadiusToken;
25
+ /** Apply a named elevation token as shadow. */
26
+ elevation?: ElevationToken;
27
+ /** Flex direction shortcut. */
28
+ direction?: 'row' | 'column';
29
+ /** Align items shortcut. */
30
+ align?: 'flex-start' | 'center' | 'flex-end' | 'stretch';
31
+ /** Justify content shortcut. */
32
+ justify?: 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around';
33
+ /** Gap between children. Raw pixel value. */
34
+ gap?: number;
35
+ /** Full width. */
36
+ fullWidth?: boolean;
37
+ /** Full height. */
38
+ fullHeight?: boolean;
39
+ }
40
+
41
+ /**
42
+ * Primitive layout component that maps design tokens to React Native styles.
43
+ *
44
+ * ```tsx
45
+ * <Box bg="shapePrimary" p="lg" rounded="xl" elevation="md">
46
+ * <Text>Hello</Text>
47
+ * </Box>
48
+ * ```
49
+ */
50
+ export function Box({
51
+ bg,
52
+ p,
53
+ px,
54
+ py,
55
+ m,
56
+ mx,
57
+ my,
58
+ rounded,
59
+ elevation: elev,
60
+ direction,
61
+ align,
62
+ justify,
63
+ gap,
64
+ fullWidth,
65
+ fullHeight,
66
+ style,
67
+ children,
68
+ ...rest
69
+ }: BoxProps) {
70
+ const theme = useTheme();
71
+
72
+ return (
73
+ <View
74
+ style={[
75
+ bg && { backgroundColor: theme.colors[bg] },
76
+ p !== undefined && { padding: spacing[p] },
77
+ px !== undefined && { paddingHorizontal: spacing[px] },
78
+ py !== undefined && { paddingVertical: spacing[py] },
79
+ m !== undefined && { margin: spacing[m] },
80
+ mx !== undefined && { marginHorizontal: spacing[mx] },
81
+ my !== undefined && { marginVertical: spacing[my] },
82
+ rounded !== undefined && { borderRadius: radius[rounded] },
83
+ elev !== undefined && elevation[elev],
84
+ direction !== undefined && { flexDirection: direction },
85
+ align !== undefined && { alignItems: align },
86
+ justify !== undefined && { justifyContent: justify },
87
+ gap !== undefined && { gap },
88
+ fullWidth && { width: '100%' as never },
89
+ fullHeight && { height: '100%' as never },
90
+ style,
91
+ ]}
92
+ {...rest}>
93
+ {children}
94
+ </View>
95
+ );
96
+ }
97
+
98
+ // Re-export ViewProps so consumers don't need to import react-native separately.
99
+ export type { ViewProps } from 'react-native';